Effective Unit & Integration Testing Pitfalls and How to Avoid Them Atanas Gardev, Petyo Dimitrov 30-05-2024

About us Atanas Gardev Software Architect 20 years’ experience in IT, Java Backend focus 3 kids :) Petyo Dimitrov Senior Software Architect 17 years’ experience in IT no kids (yet)

Agenda 01 What is a good test? 02 Anti-patterns 03 How to make a better test? 04 AI benefits in testing 05 Q&A 3

What is a good test?

5

Confidence 6

Quick feedback 7

Robust 8

Maintainable 9

Anti-patterns & fixes

Context 11

Spock structure 12 def “test method name”() { given: “prepare” … when: “stimulus” … then: “assertions” … }

Spock syntax def user = Mock(User) user.name >> “Johnny Bravo” user.isDeleted == false 1 * converter.convert(user) 0 * converter._ 13

Anti-pattern #1 def user = Mock(User) user.name >> “Johnny Bravo” user.address >> “Sofia” user.type >> UserType.Corporate user.status >> UserStatus.Active user.isDeleted >> false user.lastLogin >> new Date() 14

Mocking overuse – solution (1) def johnny = new User(“Johnny Bravo”, “Sofia”, UserType.Active) johnny.isDeleted = false johnny.lastLogin = new Date() (2) def johnny = UserMother.johnny() 15

Object Mother pattern UserMother.johnny() UserMother.littleSuzy() AddressMother.sofia() UserMother.johnny() .withAddress( AddressMother.sofia().build()) .build() 16

Anti-pattern #2 def “user conversion works”() { given: def input = new User(“Johnny Bravo”, “Sofia”, Active) when: def result = underTest.convert(input) then: result.name == “Johnny Bravo” result.address == “Sofia” result.type == Active } 17

Repeating yourself – solution def “user conversion works”() { given: def input = UserMother.johnny() when: def result = underTest.convert(input) then: result.name == input.name result.address == input.address result.type == input.type } 18

Utility classes def “user conversion works”() { given: def input = UserMother.johnny() when: def result = underTest.convert(input) then: Utils.matchProperties(result, input, “name”, “address”, “type”, …) } 19

Anti-pattern #3 def “user service can create user”() { given: def user = UserMother.johnny() def userForm = new UserForm(user.name, user.address, user.type) when: def result = underTest.createUser(userForm) then: 1 * userRepository.persist(_ as User) >> user 0 * userRepository._ result == user 20 }

Testing implementation vs contract – solution def “user service can create user”() { given: def user = UserMother.johnny() def userForm = new UserForm(user.name, user.address, user.type) userRepository.persist(_ as User) >> user when: def result = underTest.createUser(userForm) then: 0 * userRepository._ result == user 21 }

Anti-pattern #4 – setup @PostMapping @ResponseBody public UserDto create(@RequestBody @Valid UserForm userForm) { User user = userService.create(userForm); return userConverter.convert(user); } 22

Anti-pattern #4 def “controller calls the service and converter”() { given: def user = UserMother.johnny() def userForm = new UserForm(user.name, user.address, user.type) when: def result = underTest.create(form) then: 1 * userService.create(form) >> user 1 * userConverter.convert(user) >> … result.name == user.name result.address == user.address } 23

Don’t write unit tests, write integration tests! 24

Why? • A few Integration/API Tests replace numerous Unit Tests • Easy to develop (e.g. in Spring - @DataJpaTest, @MockBean, MockMvc) • Work with real REST requests / async events • Allow access to the database (where test data may be set up in advance) • 3rd party system calls are handled by mocking the API rather than our client code 25

A refresher 26

Integration test example (1) def “user is created, updated and deactivated after some time”() { given: def user = UserMother.johnny() when: def createdUserDto = /** trigger create REST API */ then: createdUserDto.name == user.name userRepository.findByName(user.name) != null 27

Integration test example (2) when: user.lastLogin = now().minusDays(30) def updatedUserDto = /** trigger update REST API / then: updatedUserDto.name == user.name updatedUserDto.lastLogin == user.lastLogin when: jobsUtil.triggerUserPeriodicJob() def userDto = /* trigger read REST API */ then: userDto.name == user.name userDto.status == UserStatus.Deactivated 28

Don’t write integration tests, write unit tests! 29

Source code example def updateState(newState: UserState) { var validStates = [] when (this.state) { Inactive -> validStates = [Active, Deleted] Active -> validStates = [Inactive] } if (validStates.contains(newState)) { state = newState } else { throw IllegalArgumentException() } } 30

Unit test example @Unroll def “updateState should change state from #currentState to #newState”() { given: def user = new User(state: currentState) when: user.updateState(newState) then: user.state == newState where: 31 } currentState | newState UserState.Inactive | UserState.Active UserState.Inactive | UserState.Deleted UserState.Active | UserState.Inactive

AI benefits in testing

Popular tools ChatGPT 33 GitHub Copilot

Explanation 34

Test generation 35

Test data 36

37 Debug

38 Debug

AI considerations • • • • • • 39 Syntax & logical errors Limited edge case coverage Not as creative* Difficulty with complex tests Data Privacy Intellectual Property

ChatGPT config 40

Copilot config 41

Summary • Don’t Repeat Yourself • Avoid mock overuse • Test contracts, not implementation internals • Don’t test for the sake of testing • Distribute your tests across the test pyramid • Let AI help you 42

To me, legacy code is simply code… without tests Michael Feathers Working Effectively With Legacy Code

Questions? 44

Thanks! Atanas Gardev Software Architect atanas.gardev@musala.com Petyo Dimitrov Senior Software Architect petyo.dimitrov@musala.com