Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Семён Киреков — Spring Data JPA. Антипаттерны т...

Sponsored · Your Podcast. Everywhere. Effortlessly. Share. Educate. Inspire. Entertain. You do you. We'll handle the rest. →

Семён Киреков — Spring Data JPA. Антипаттерны тестирования

За свою карьеру спикер столкнулся с рядом (а некоторые даже попробовал) антипаттернов тестирования при использовании Spring Data JPA. Они не только не помогают, но и усложняют поддержку кода и вызывают раздражение.

В рамках доклада Семен расскажет вам о таких антипаттернах, как избыточный coupling на декларацию сущностей, лишние зависимости, best practices для создания тестовых данных и транзакционные сценарии. А также покажет паттерны, на которые следует их заменить, чтобы упростить жизнь при написании тестов.

Moscow JUG

July 14, 2022

Transcript

105 slides
  1. О себе •Киреков Семен •Java Dev и Team Lead в

    «МТС Диджитал» Центр Big Data •Java-декан в МТС.Тета 2
  2. План доклада • Антипаттерны • Паттерны, на которые стоит заменить

    • Неочевидные моменты • SimonHarmonicMinor/spring-data-jpa-efficient-testing 3
  3. Домен 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 } } с
  4. 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); }
  5. 10 @SpringBootTest @AutoConfigureTestDatabase class RobotUpdateServiceTestH2DirtiesContext { @Autowired private RobotUpdateService service;

    @Autowired private RobotRepository robotRepository; @MockBean private RobotRestrictions robotRestrictions; … }
  6. 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 } }
  7. 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:")); }
  8. 13 public static EmbeddedDatabaseConnection get(ClassLoader classLoader) { for (EmbeddedDatabaseConnection candidate

    : EmbeddedDatabaseConnection.values()) { if (candidate != NONE && ClassUtils.isPresent(candidate.getDriverClassName(), classLoader) return candidate; } } return NONE; }
  9. 14 @SpringBootTest @AutoConfigureTestDatabase class RobotUpdateServiceTestH2DirtiesContext { @Autowired private RobotUpdateService service;

    @Autowired private RobotRepository robotRepository; @MockBean private RobotRestrictions robotRestrictions; … }
  10. 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>)]
  11. Протестируем 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>)]
  12. 19

  13. 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()); }
  14. Object Mother 23 https://bit.ly/3GHMWZc public class RobotFactory { public static

    Robot createWithName(String name) { … } public static Robot createWithType(Type type) { … } }
  15. 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) { … } }
  16. 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 }
  17. Пример использования Lombok Builder 27 final var robot = Robot.builder()

    .switched(false) .type(DRIVER) .name("some_name") .build();
  18. 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; } }
  19. 30 aRobot().switched(false).build() var robot = aRobot().name("my_robot"); … var switchedOn =

    robot.switched(true).build(); var vacuum = robot.type(VACUUM).build();
  20. 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 )
  21. 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 )
  22. Вывод. Инстанцирование сущностей напрямую • Инстанцирование через конструктор – позиционные

    аргументы • Сеттеры – ошибки в рантайме, verbose • Object Mother – простые сущности • Test Data Builder – универсальный • Object Mother is OK with Kotlin 34
  23. 37

  24. TestEntityManager под капотом 39 public final EntityManager getEntityManager() { EntityManager

    manager = EntityManagerFactoryUtils.getTransactionalEntityManager(this.entityManagerFact ory); Assert.state(manager != null, "No transactional EntityManager found"); return manager; }
  25. TestDbFacade 42 public class TestDBFacade { @Autowired private TestEntityManager testEntityManager;

    @Autowired private TransactionTemplate transactionTemplate; @Autowired private JdbcTemplate jdbcTemplate; … } https://bit.ly/3KpsKOf
  26. TestDbFacade 43 https://bit.ly/3KpsKOf public void cleanDatabase() { transactionTemplate.execute(status -> {

    JdbcTestUtils.deleteFromTables(jdbcTemplate, "robot"); return null; }); }
  27. TestDbFacade 44 https://bit.ly/3KpsKOf public <T> T find(Object id, Class<T> entityClass)

    { return transactionTemplate.execute( status -> testEntityManager.find(entityClass, id) ); }
  28. TestDbFacade 45 https://bit.ly/3KpsKOf public <T> T save(TestBuilder<T> builder) { return

    transactionTemplate.execute( status -> testEntityManager.persistAndFlush(builder.build()) ); }
  29. Посты/комменты 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") )) ); }
  30. TestDbFacade 47 https://bit.ly/3KpsKOf @TestConfiguration public static class Config { @Bean

    public TestDBFacade testDBFacade() { return new TestDBFacade(); } }
  31. 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()); }
  32. SQL Annotation 51 @Test @Sql(value = "/insert_data.sql", executionPhase = BEFORE_TEST_METHOD)

    @Sql(value = "/delete_data.sql", executionPhase = AFTER_TEST_METHOD) void shouldSwitchOnSuccessfully() { … }
  33. Проблемы SQL Annotation • Нет статической типизации • Проблемы со

    сложными объектами (JSON to Java Object) • Каскадные изменения при ALTER TABLE • Как найти сущность с Sequence ID? 52
  34. Вывод. Зависимость на репозитории • Затрудняет понимание теста • Лишние

    зависимости • Альтернативы: @SQL, TestEntityManager, Test DB Facade 53
  35. 56 @DataJpaTest @Import(TestDBFacade.Config.class) class RobotUpdateServiceTestH2DataJpa { @Autowired private RobotUpdateService service;

    @Autowired private TestDBFacade db; @MockBean private RobotRestrictions robotRestrictions; … }
  36. 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
  37. 58 @DataJpaTest @Import(TestDBFacade.Config.class) class RobotUpdateServiceTestH2DataJpa { @Autowired private RobotUpdateService service;

    @Autowired private TestDBFacade db; @MockBean private RobotRestrictions robotRestrictions; … }
  38. 59 @TestConfiguration static class Config { @Bean public RobotUpdateService service(

    RobotRepository robotRepository, RobotRestrictions robotRestrictions ) { return new RobotUpdateService(robotRepository, robotRestrictions); } }
  39. 63

  40. Propagation 64 @Transactional public void switchOnRobot(Long robotId) { final var

    robot = robotRepository.findById(robotId) .orElseThrow(); robot.setSwitched(true); robotRepository.saveAndFlush(robot); robotRestrictions.checkSwitchOn(robotId); }
  41. 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
  42. Что делать? 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); }
  43. 69

  44. 70

  45. Пойдем дальше 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); }
  46. 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:
  47. 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
  48. 76

  49. 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'
  50. Программные транзакции 79 @Test void test() { // given ...

    // when transactionTemplate.execute(status -> service.mandatoryTransactionOperation() ); // then ... }
  51. 82 @Transactional public void switchOnRobot(Long robotId) { final var robot

    = robotRepository.findById(robotId) .orElseThrow(); robot.setSwitched(true); robotRepository.flush(); robotRestrictions.checkSwitchOn(robotId); }
  52. 1. Если робот уже включен, нельзя отправлять запрос повторно. 2.

    Не более трех включенных роботов каждого типа 84
  53. 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()) ); } }
  54. 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; } }
  55. 87 Есть три робота, которые пытаемся включить: 1. DRIVER –

    включен 2. LOADER – выключен 3. VACUUM – выключен. В системе есть три других VACUUM робота, которые включены. Ожидаем: 1. DRIVER – RESTRICTED 2. LOADER – ALLOWED 3. VACUUM - RESTRICTED
  56. 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())); }
  57. 89 Transaction silently rolled back because it has been marked

    as rollback-only org.springframework.transaction.UnexpectedRollbackException:
  58. 90

  59. 92

  60. 93

  61. 96

  62. 97

  63. 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 { }
  64. 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 ""; }
  65. Бонусный антипаттерн 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
  66. Бонусный антипаттерн 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 {}; }
  67. Общие выводы • Пишем интеграционные тесты (Embedded DB/Testcontainers) • Убираем

    coupling на декларацию сущностей (builder/object mother) • Избегаем transactional tests • Не боимся внедрять кастомные утилиты (TestDBFacade) • Acceptance tests/End-to-End tests необходимы 104

More Decks by Moscow JUG

Other Decks in Programming