Effective Unit and Integration Testing: Pitfalls and How to Avoid Them

A presentation at ISTAcon in November 2024 in Sofia, Bulgaria by Petyo Dimitrov

Slide 1

Slide 1

Effective Unit and Integration Testing Petyo Dimitrov | Senior Software Architect

Slide 2

Slide 2

Agenda What is a good test? Anti-patterns How to improve your tests?

Slide 3

Slide 3

What is a good test?

Slide 4

Slide 4

Provides Confidence

Slide 5

Slide 5

Quick Feedback

Slide 6

Slide 6

Robust

Slide 7

Slide 7

Maintainable

Slide 8

Slide 8

Anti-patterns and fixes

Slide 9

Slide 9

Test structure def “test method name”() { given: “prepare” … when: “stimulus” … then: “assertion” … }

Slide 10

Slide 10

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

Slide 11

Slide 11

Context

Slide 12

Slide 12

Anti-pattern #1: Mocking overuse

Slide 13

Slide 13

def user = Mock(User) user.name >> “Johnny Bravo” user.address >> “Sofia” user.status >> UserStatus.Active user.isDeleted >> false user.lastLogin >> new Date()

Slide 14

Slide 14

Use real objects def johnny = new User(“Johnny Bravo”, “Sofia”, UserStatus.Active) johnny.isDeleted = false johnny.lastLogin = new Date()

Slide 15

Slide 15

Invest in test data def johnny = TestData.JOHNNY def littleSuzy = TestData.littleSuzy()

Slide 16

Slide 16

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

Slide 17

Slide 17

Anti-pattern #2: Repetition

Slide 18

Slide 18

def “user conversion works”() { given: def input = new User(“Johnny Bravo”, “Sofia”, UserStatus.Active) when: def result = converter.convert(input) } then: result.name == “Johnny Bravo” result.address == “Sofia” result.status == Active

Slide 19

Slide 19

def “user conversion works”() { given: def input = new User(“Johnny Bravo”, “Sofia”, UserStatus.Active) when: def result = underTest.convert(input) } then: result.name == input.name result.address == input.address result.status == input.status

Slide 20

Slide 20

def “user conversion works”() { given: def input = new User(“Johnny Bravo”, “Sofia”, UserStatus.Active) when: def result = underTest.convert(input) } then: Utils.matchProperties(result, input, “name”, “address”, “status”,…)

Slide 21

Slide 21

Anti-pattern #3: Testing the implementation

Slide 22

Slide 22

def “user service can create user”() { given: def user = UserMother.johnny().build() def userForm = new UserForm(user.name, …) when: def result = underTest.createUser(userForm) } then: 1 * otherService.isNameOK(_ as UserForm) >> true 1 * userRepository.persist(_ as User) >> user 0 * userRepository._ result == user

Slide 23

Slide 23

def “user service can create user”() { given: def user = UserMother.johnny().build() def userForm = new UserForm(user.name, …) // name matches rules otherService.isNameOK() >> true // user is persisted userRepository.persist() >> user when: def result = underTest.createUser(userForm) } then: 0 * userRepository._ result == user

Slide 24

Slide 24

Anti-pattern #4: Ineffective tests

Slide 25

Slide 25

@PostMapping @ResponseBody public UserDto create(@RequestBody @Valid UserForm form){ User user = userService.create(form); return userConverter.convert(user); }

Slide 26

Slide 26

def “controller calls the service and converter”() { given: def user = UserMother.johnny().build() def userForm = new UserForm(user.name, …) userService.create() >> user userConverter.convert() >> … when: def result = underTest.create(form) then: result.name == user.name result.address == user.address }

Slide 27

Slide 27

Don’t write unit tests! Do write integration tests!

Slide 28

Slide 28

A refresher

Slide 29

Slide 29

def “user is created, updated and deactivated”() { given: def user = UserMother.johnny().build() when: def createdUserDto = /* trigger create REST API */ then: createdUserDto.name == user.name userRepository.findByName(user.name) != null …

Slide 30

Slide 30

when: user.lastLogin = now().minusDays(30) def updatedUserDto = /* trigger update REST API */ then: updatedUserDto.name == user.name updatedUserDto.lastLogin == user.lastLogin

Slide 31

Slide 31

when: jobsUtil.triggerUserPeriodicJob() def userDto = /* trigger read REST API */ then: userDto.name == user.name userDto.status == UserStatus.Inactivate

Slide 32

Slide 32

Don’t write integration tests! Do write unit tests!

Slide 33

Slide 33

@Unroll def “update changes status from #currentStatus to #newStatus”() { given: def user = new User(status: currentStatus) when: user.updateStatus(newStatus) then: user.status == newStatus where: currentStatus | newStatus UserStatus.Inactive | UserStatus.Active UserStatus.Inactive | UserStatus.Deleted … }

Slide 34

Slide 34

Summary Avoid mock overuse Don’t Repeat Yourself Test contracts, not implementations Don’t test for the sake of testing Distribute tests across the test pyramid

Slide 35

Slide 35

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

Slide 36

Slide 36

Thanks! petyo.dimitrov@gmail.com petyo.dimitrov@musala.com www.linkedin.com/in/petyo-dimitrov