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

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

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

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

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

Moscow JUG

July 14, 2022
Tweet

More Decks by Moscow JUG

Other Decks in Programming

Transcript

  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