Share
Embed
Семён Киреков — Spring Data JPA. Антипаттерны тестирования
За свою карьеру спикер столкнулся с рядом (а некоторые даже попробовал) антипаттернов тестирования при использовании Spring Data JPA. Они не только не помогают, но и усложняют поддержку кода и вызывают раздражение.
В рамках доклада Семен расскажет вам о таких антипаттернах, как избыточный coupling на декларацию сущностей, лишние зависимости, best practices для создания тестовых данных и транзакционные сценарии. А также покажет паттерны, на которые следует их заменить, чтобы упростить жизнь при написании тестов.
Moscow JUG
July 14, 2022
Transcript
105 slides
-
О себе •Киреков Семен •Java Dev и Team Lead в
«МТС Диджитал» Центр Big Data •Java-декан в МТС.Тета 2 -
План доклада • Антипаттерны • Паттерны, на которые стоит заменить
• Неочевидные моменты • SimonHarmonicMinor/spring-data-jpa-efficient-testing 3 -
Бизнес-область Система по управлению роботами. Включение, выключение и так далее.
4 Photo by Jason Leung on Unsplash -
Домен 5 @Entity @Table(name = "robot") public class Robot {
@Id @GeneratedValue(strategy = IDENTITY) @Column(name = "robot_id") private Long id; @NotNull private String name; @NotNull private boolean switched; @Enumerated(STRING) @NotNull private Type type; public enum Type { DRIVER, LOADER, VACUUM } } с -
7 @Service public class RobotUpdateService { private final RobotRepository robotRepository;
private final RobotRestrictions robotRestrictions; @Transactional public void switchOnRobot(Long robotId) { final var robot = robotRepository.findById(robotId) .orElseThrow(); robot.setSwitched(true); robotRepository.flush(); robotRestrictions.checkSwitchOn(robotId); } -
10 @SpringBootTest @AutoConfigureTestDatabase class RobotUpdateServiceTestH2DirtiesContext { @Autowired private RobotUpdateService service;
@Autowired private RobotRepository robotRepository; @MockBean private RobotRestrictions robotRestrictions; … } -
11 @Target({ ElementType.TYPE, ElementType.METHOD }) @Retention(RetentionPolicy.RUNTIME) @Documented @Inherited @ImportAutoConfiguration @PropertyMapping("spring.test.database")
public @interface AutoConfigureTestDatabase { @PropertyMapping(skip = SkipPropertyMapping.ON_DEFAULT_VALUE) Replace replace() default Replace.ANY; EmbeddedDatabaseConnection connection() default EmbeddedDatabaseConnection.NONE; enum Replace { ANY, AUTO_CONFIGURED, NONE } } -
12 public enum EmbeddedDatabaseConnection { NONE(null, null, null, (url) ->
false), H2(EmbeddedDatabaseType.H2, DatabaseDriver.H2.getDriverClassName(), "jdbc:h2:mem:%s;DB_CLOSE_DELAY=- 1;DB_CLOSE_ON_EXIT=FALSE", (url) -> url.contains(":h2:mem")), DERBY(EmbeddedDatabaseType.DERBY, DatabaseDriver.DERBY.getDriverClassName(), "jdbc:derby:memory:%s;create=true", (url) -> true), HSQLDB(EmbeddedDatabaseType.HSQL, DatabaseDriver.HSQLDB.getDriverClassName(), "org.hsqldb.jdbcDriver", "jdbc:hsqldb:mem:%s", (url) -> url.contains(":hsqldb:mem:")); } -
13 public static EmbeddedDatabaseConnection get(ClassLoader classLoader) { for (EmbeddedDatabaseConnection candidate
: EmbeddedDatabaseConnection.values()) { if (candidate != NONE && ClassUtils.isPresent(candidate.getDriverClassName(), classLoader) return candidate; } } return NONE; } -
14 @SpringBootTest @AutoConfigureTestDatabase class RobotUpdateServiceTestH2DirtiesContext { @Autowired private RobotUpdateService service;
@Autowired private RobotRepository robotRepository; @MockBean private RobotRestrictions robotRestrictions; … } -
17 @Test void shouldSwitchOnSuccessfully() { final var robot = new
Robot(); robot.setSwitched(false); robot.setType(DRIVER); robot.setName("some_name"); robotRepository.save(robot); doNothing().when(robotRestrictions).checkSwitchOn(robot.getId()); service.switchOnRobot(robot.getId()); final var savedRobot = robotRepository.findById(robot.getId()).orElseThrow(); assertTrue(savedRobot.isSwitched()); } Протестируем Commit Creating new transaction with name [SimpleJpaRepository.save]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT Hibernate: insert into robot (robot_id, name, switched, type) values (null, ?, ?, ?) Committing JPA transaction on EntityManager [SessionImpl(115584215<open>)] Creating new transaction with name [RobotUpdateService.switchOnRobot]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT Hibernate: select robot0_.robot_id as robot_id1_1_0_, robot0_.name as name2_1_0_, robot0_.switched as switched3_1_0_, robot0_.type as type4_1_0_ from robot robot0_ where robot0_.robot_id=? Hibernate: update robot set name=?, switched=?, type=? where robot_id=? Committing JPA transaction on EntityManager [SessionImpl(93418194<open>)] Creating new transaction with name [SimpleJpaRepository.findById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly Hibernate: select robot0_.robot_id as robot_id1_1_0_, robot0_.name as name2_1_0_, robot0_.switched as switched3_1_0_, robot0_.type as type4_1_0_ from robot robot0_ where robot0_.robot_id=? Committing JPA transaction on EntityManager [SessionImpl(31874125<open>)] -
Протестируем Rollback 18 @Test void shouldRollbackIfCannotSwitchOn() { final var robot
= new Robot(); robot.setSwitched(false); robot.setType(DRIVER); robot.setName("some_name"); robotRepository.save(robot); doThrow(new OperationRestrictedException("")).when(robotRestrictions) .checkSwitchOn(robot.getId()); assertThrows(OperationRestrictedException.class, () -> service.switchOnRobot(robot.getId())); final var savedRobot = robotRepository.findById(robot.getId()).orElseThrow(); assertFalse(savedRobot.isSwitched()); } Creating new transaction with name [RobotUpdateService.switchOnRobot]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT Hibernate: select robot0_.robot_id as robot_id1_1_0_, robot0_.name as name2_1_0_, robot0_.switched as switched3_1_0_, robot0_.type as type4_1_0_ from robot robot0_ where robot0_.robot_id=? Hibernate: update robot set name=?, switched=?, type=? where robot_id=? Rolling back JPA transaction on EntityManager [SessionImpl(1969238242<open>)] -
21 @Test void shouldRollbackIfCannotSwitchOn() { final var robot = new
Robot(); robot.setSwitched(false); robot.setType(DRIVER); robot.setName("some_name"); robotRepository.save(robot); doThrow(new OperationRestrictedException("")).when(robotRestrictions).checkSwitchOn(robot.getId()); assertThrows(OperationRestrictedException.class, () -> service.switchOnRobot(robot.getId())); final var savedRobot = robotRepository.findById(robot.getId()).orElseThrow(); assertFalse(savedRobot.isSwitched()); } -
Object Mother 23 https://bit.ly/3GHMWZc public class RobotFactory { public static
Robot createWithName(String name) { … } public static Robot createWithType(Type type) { … } } -
Object Mother 24 https://bit.ly/3GHMWZc public class RobotFactory { public static
Robot createWithName(String name) { … } public static Robot createWithType(Type type) { … } public static Robot createWithNameAndType(String name, Type type) { … } public static Robot createWithTypeAndSwitched(Type type, boolean switched) { … } public static Robot createWithNameAndTypeAndSwitched(String name, Type type, boolean switched) { … } } -
Lombok Builder 26 @Entity @Table(name = "robot") @Builder @NoArgsConstructor public
class Robot { @Id @GeneratedValue(strategy = IDENTITY) @Column(name = "robot_id") private Long id; @NotNull private String name; @NotNull private boolean switched; @Enumerated(STRING) @NotNull private Type type; public enum Type { DRIVER, LOADER, VACUUM } -
Пример использования Lombok Builder 27 final var robot = Robot.builder()
.switched(false) .type(DRIVER) .name("some_name") .build(); -
Test Data Builder with Lombok 29 https://bit.ly/3532gSo @AllArgsConstructor @NoArgsConstructor(staticName =
"aRobot") @With public class RobotTestBuilder implements TestBuilder<Robot> { private String name = ""; private boolean switched = false; private Type type = DRIVER; @Override public Robot build() { final var server = new Robot(); server.setName(name); server.setSwitched(switched); server.setType(type); return server; } } -
30 aRobot().switched(false).build() var robot = aRobot().name("my_robot"); … var switchedOn =
robot.switched(true).build(); var vacuum = robot.type(VACUUM).build(); -
Easy Random 31 EasyRandom easyRandom = new EasyRandom(); Robot robot
= easyRandom.nextObject(Robot.class); testImplementation 'org.jeasy:easy-random-core:5.0.0' Robot( id=-5106534569952410475, name=eOMtThyhVNLWUZNRcBaQKxI, switched=true, type=VACUUM ) -
Easy Random 32 var parameters = new EasyRandomParameters() .excludeField(field ->
field.getName().equals("id")); var easyRandom = new EasyRandom(parameters); var robot = easyRandom.nextObject(Robot.class); Robot( id=null, name=FypEwUZ, switched=false, type=DRIVER ) -
Вывод. Инстанцирование сущностей напрямую • Инстанцирование через конструктор – позиционные
аргументы • Сеттеры – ошибки в рантайме, verbose • Object Mother – простые сущности • Test Data Builder – универсальный • Object Mother is OK with Kotlin 34 -
Причины • Сущностей может быть много • Затрудняет понимание •
Порядок удаления 36 -
TestEntityManager под капотом 39 public final EntityManager getEntityManager() { EntityManager
manager = EntityManagerFactoryUtils.getTransactionalEntityManager(this.entityManagerFact ory); Assert.state(manager != null, "No transactional EntityManager found"); return manager; } -
TestDbFacade 42 public class TestDBFacade { @Autowired private TestEntityManager testEntityManager;
@Autowired private TransactionTemplate transactionTemplate; @Autowired private JdbcTemplate jdbcTemplate; … } https://bit.ly/3KpsKOf -
TestDbFacade 43 https://bit.ly/3KpsKOf public void cleanDatabase() { transactionTemplate.execute(status -> {
JdbcTestUtils.deleteFromTables(jdbcTemplate, "robot"); return null; }); } -
TestDbFacade 44 https://bit.ly/3KpsKOf public <T> T find(Object id, Class<T> entityClass)
{ return transactionTemplate.execute( status -> testEntityManager.find(entityClass, id) ); } -
TestDbFacade 45 https://bit.ly/3KpsKOf public <T> T save(TestBuilder<T> builder) { return
transactionTemplate.execute( status -> testEntityManager.persistAndFlush(builder.build()) ); } -
Посты/комменты 46 TestBuilder<User> user = db.persistedOnce(aUser().login("login")); TestBuilder<Comment> comment = aComment().author(user);
for (int i = 0; i < 10; i++) { db.save( aPost() .rating(i) .author(user) .comments(List.of( comment.text("comment1"), comment.text("comment2") )) ); } -
TestDbFacade 47 https://bit.ly/3KpsKOf @TestConfiguration public static class Config { @Bean
public TestDBFacade testDBFacade() { return new TestDBFacade(); } } -
49 @Test void shouldSwitchOnSuccessfully() { final var id = db.save(aRobot().switched(false)).getId();
doNothing().when(robotRestrictions).checkSwitchOn(id); service.switchOnRobot(id); final var savedServer = db.find(id, Robot.class); assertTrue(savedServer.isSwitched()); } @Test void shouldSwitchOnSuccessfully() { final var robot = new Robot(); robot.setSwitched(false); robot.setType(DRIVER); robot.setName("some_name"); robotRepository.save(robot); doNothing().when(robotRestrictions) .checkSwitchOn(robot.getId()); service.switchOnRobot(robot.getId()); final var savedRobot = robotRepository.findById(robot.getId()).orElseThrow(); assertTrue(savedRobot.isSwitched()); } -
SQL Annotation 51 @Test @Sql(value = "/insert_data.sql", executionPhase = BEFORE_TEST_METHOD)
@Sql(value = "/delete_data.sql", executionPhase = AFTER_TEST_METHOD) void shouldSwitchOnSuccessfully() { … } -
Проблемы SQL Annotation • Нет статической типизации • Проблемы со
сложными объектами (JSON to Java Object) • Каскадные изменения при ALTER TABLE • Как найти сущность с Sequence ID? 52 -
Вывод. Зависимость на репозитории • Затрудняет понимание теста • Лишние
зависимости • Альтернативы: @SQL, TestEntityManager, Test DB Facade 53 -
56 @DataJpaTest @Import(TestDBFacade.Config.class) class RobotUpdateServiceTestH2DataJpa { @Autowired private RobotUpdateService service;
@Autowired private TestDBFacade db; @MockBean private RobotRestrictions robotRestrictions; … } -
57 @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Documented @Inherited @BootstrapWith(DataJpaTestContextBoots trapper.class) @ExtendWith(SpringExtension.class) @OverrideAutoConfiguration(enabled =
false) @TypeExcludeFilters(DataJpaTypeExclude Filter.class) @Transactional @AutoConfigureCache @AutoConfigureDataJpa @AutoConfigureTestDatabase @AutoConfigureTestEntityManager @ImportAutoConfiguration public @interface DataJpaTest -
58 @DataJpaTest @Import(TestDBFacade.Config.class) class RobotUpdateServiceTestH2DataJpa { @Autowired private RobotUpdateService service;
@Autowired private TestDBFacade db; @MockBean private RobotRestrictions robotRestrictions; … } -
59 @TestConfiguration static class Config { @Bean public RobotUpdateService service(
RobotRepository robotRepository, RobotRestrictions robotRestrictions ) { return new RobotUpdateService(robotRepository, robotRestrictions); } } -
Propagation 64 @Transactional public void switchOnRobot(Long robotId) { final var
robot = robotRepository.findById(robotId) .orElseThrow(); robot.setSwitched(true); robotRepository.saveAndFlush(robot); robotRestrictions.checkSwitchOn(robotId); } -
65 @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Documented @Inherited @BootstrapWith(DataJpaTestContextBoots trapper.class) @ExtendWith(SpringExtension.class) @OverrideAutoConfiguration(enabled =
false) @TypeExcludeFilters(DataJpaTypeExclude Filter.class) @Transactional @AutoConfigureCache @AutoConfigureDataJpa @AutoConfigureTestDatabase @AutoConfigureTestEntityManager @ImportAutoConfiguration public @interface DataJpaTest -
Что делать? 67 @Transactional(propagation = REQUIRES_NEW) public void switchOnRobot(Long robotId)
{ final var robot = robotRepository.findById(robotId) .orElseThrow(); robot.setSwitched(true); robotRepository.saveAndFlush(robot); robotRestrictions.checkSwitchOn(robotId); } -
Пойдем дальше 71 @Transactional(propagation = REQUIRES_NEW, isolation = READ_UNCOMMITTED) public
void switchOnRobot(Long robotId) { final var robot = robotRepository.findById(robotId) .orElseThrow(); robot.setSwitched(true); robotRepository.saveAndFlush(robot); robotRestrictions.checkSwitchOn(robotId); } -
72 Hibernate: update robot set name=?, switched=?, type=? where robot_id=?
SQL Error: 50200, SQLState: HYT00 Время ожидания блокировки таблицы {0} истекло Timeout trying to lock table {0}; SQL statement: Dirty Write: -
74 @Transactional public void switchOnRobot(Long robotId) { final var robot
= robotRepository.findById(robotId) .orElseThrow(); robot.setSwitched(true); robotRepository.saveAndFlush(robot); robotRestrictions.checkSwitchOn(robotId); } @DataJpaTest @Import(TestDBFacade.Config.class) @Transactional(propagation = NOT_SUPPORTED) class RobotUpdateServiceTestH2DataJpa -
Mandatory Propagation 78 @Transactional(propagation = MANDATORY) public void mandatoryTransactionOperation(…) {
… } No existing transaction found for transaction marked with propagation 'mandatory’ org.springframework.transaction.IllegalTransactionStateException: No existing transaction found for transaction marked with propagation 'mandatory' -
Программные транзакции 79 @Test void test() { // given ...
// when transactionTemplate.execute(status -> service.mandatoryTransactionOperation() ); // then ... } -
Вывод • Транзакционные тесты допустимы в readonly-операциях • Если транзакция
неизбежна, используем TransactionTemplate 80 -
82 @Transactional public void switchOnRobot(Long robotId) { final var robot
= robotRepository.findById(robotId) .orElseThrow(); robot.setSwitched(true); robotRepository.flush(); robotRestrictions.checkSwitchOn(robotId); } -
1. Если робот уже включен, нельзя отправлять запрос повторно. 2.
Не более трех включенных роботов каждого типа 84 -
85 @Transactional(readOnly = true) public void checkSwitchOn(Long robotId) { final
var robot = robotRepository.findById(robotId) .orElseThrow(); if (robot.isSwitched()) { throw new OperationRestrictedException( format("Robot %s is already switched on", robot.getName()) ); } final var count = robotRepository.countAllByTypeAndIdNot(robot.getType(), robotId); if (count >= 3) { throw new OperationRestrictedException( format("There is already 3 switched on robots of type %s", robot.getType()) ); } } -
86 @Transactional(readOnly = true) public Map<Long, OperationStatus> getRobotsSwitchOnStatus(Collection<Long> robotIds) {
final var result = new HashMap<Long, OperationStatus>(); for (Long robotId : robotIds) { result.put(robotId, getOperationStatus(robotId)); } return result; } private OperationStatus getOperationStatus(Long robotId) { try { robotRestrictions.checkSwitchOn(robotId); return ALLOWED; } catch (NoSuchElementException e) { LOG.debug(format(“Robot with id %s is absent", robotId), e); return ROBOT_IS_ABSENT; } catch (OperationRestrictedException e) { LOG.debug(format(“Robot with id %s cannot be switched on", robotId), e); return RESTRICTED; } } -
87 Есть три робота, которые пытаемся включить: 1. DRIVER –
включен 2. LOADER – выключен 3. VACUUM – выключен. В системе есть три других VACUUM робота, которые включены. Ожидаем: 1. DRIVER – RESTRICTED 2. LOADER – ALLOWED 3. VACUUM - RESTRICTED -
88 @Test void shouldNotAllowSomeRobotsToSwitchOn() { final var driver = db.save(
aRobot().switched(true).type(DRIVER) ); final var loader = db.save( aRobot().switched(false).type(LOADER) ); final var vacuumTemplate = aRobot().switched(false).type(VACUUM); final var vacuum = db.save(vacuumTemplate); db.saveAll( vacuumTemplate.switched(true), vacuumTemplate.switched(true), vacuumTemplate.switched(true) ); final var robotsIds = List.of(driver.getId(), loader.getId(), vacuum.getId()); final var operations = robotAllowedOperations.getRobotsSwitchOnStatus( robotsIds ); assertEquals(RESTRICTED, operations.get(driver.getId())); assertEquals(ALLOWED, operations.get(loader.getId())); assertEquals(RESTRICTED, operations.get(vacuu,.getId())); } -
89 Transaction silently rolled back because it has been marked
as rollback-only org.springframework.transaction.UnexpectedRollbackException: -
Проблемы • N + 1 транзакций • Не работает кэш
первого уровня 94 -
98 @Transactional(readOnly = true, noRollbackFor = Exception.class) @Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME)
@Transactional(readOnly = true, noRollbackFor = Exception.class) @Documented public @interface ReadTransactional { } -
99 @Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Transactional(readOnly = true, noRollbackFor = Exception.class)
@Documented public @interface ReadTransactional { @AliasFor(annotation = Transactional.class, attribute = "value") String value() default ""; @AliasFor(annotation = Transactional.class, attribute = "transactionManager") String transactionManager() default ""; @AliasFor(annotation = Transactional.class, attribute = "label") String[] label() default {}; @AliasFor(annotation = Transactional.class, attribute = "propagation") Propagation propagation() default Propagation.REQUIRED; @AliasFor(annotation = Transactional.class, attribute = "isolation") Isolation isolation() default Isolation.DEFAULT; @AliasFor(annotation = Transactional.class, attribute = "timeout") int timeout() default TransactionDefinition.TIMEOUT_DEFAULT; @AliasFor(annotation = Transactional.class, attribute = "timeoutString") String timeoutString() default ""; } -
Выводы по Readonly • Не откатывайте readonly-транзакции • Интеграционные тесты
важны • Между бинами 101 -
Бонусный антипаттерн 102 @DataJpaTest @Import(TestDBFacade.Config.class) @Transactional(propagation = NOT_SUPPORTED) class RobotUpdateServiceTestH2DataJpaNonTransactional
@Retention(RetentionPolicy.RUNTIME) @DataJpaTest @Import(TestDBFacade.Config.class) @Transactional(propagation = NOT_SUPPORTED public @interface DBTest { } @DBTest class RobotUpdateServiceTestH2DataJpaNonTransactional -
Бонусный антипаттерн 103 @Retention(RetentionPolicy.RUNTIME) @DataJpaTest @Import(TestDBFacade.Config.class) @Transactional(propagation = NOT_SUPPORTED) public
@interface DBTest { @AliasFor(annotation = DataJpaTest.class, attribute = "properties") String[] properties() default {}; } -
Общие выводы • Пишем интеграционные тесты (Embedded DB/Testcontainers) • Убираем
coupling на декларацию сущностей (builder/object mother) • Избегаем transactional tests • Не боимся внедрять кастомные утилиты (TestDBFacade) • Acceptance tests/End-to-End tests необходимы 104