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

Writing Tests that Stand the Test of Time

Writing Tests that Stand the Test of Time

This talk was given at DACHFest (dachfest.com) in Munich, Germany.

Video: https://www.youtube.com/watch?v=Hy5ra9qDpEY

Description:
One of the promises of test driven development is the confidence and ease with which we can refactor our production code.

However, sometimes we find ourselves in a position where our tests are highly coupled to the production code and we have to change the tests every time we update our production code. At this point we start wondering if the tests we had are even worth it if we have to spend so much time to maintain them. If you have ever found yourself having to update your tests every time the behavior of a dependency of the class under test has changed, then this talk is for you.

This talk is to share with you some patterns, tools and examples that can guide us to write more maintainable tests.

Segun Famisa

November 11, 2018
Tweet

More Decks by Segun Famisa

Other Decks in Programming

Transcript

  1. Outline Introduction to TDD Challenges with TDD Testing tools in

    practice Writing maintainable tests Recap
  2. What is TDD? ”Test-Driven Development (TDD) is a technique for

    building software that guides software development by writing tests” https://martinfowler.com/bliki/TestDrivenDevelopment.html
  3. What is TDD? ”TDD is a software development process that

    relies on the repetition of a very short development cycle: requirements are turned into very specific test cases, then the software is improved to pass the new tests, only” Wikipedia
  4. How to do TDD? TL;DR Don’t write new code until

    you’ve written a test that fails. Don’t write more code than you need to make the test pass. Repeat.
  5. Why do we need tests? • Quick feedback about bugs/errors

    • Good code design • Documentation for code behavior
  6. Why do we need tests? • Quick feedback about bugs/errors

    • Good code design • Documentation for code behavior • Confident refactoring
  7. Time Tests are code too, so they take time to

    write However, time spent writing tests is an investment in the long run
  8. Maintenance overhead Challenges with TDD TDD becomes challenging when: •

    Old tests break often • Difficult to add new tests
  9. TDD becomes challenging when: • Old tests break often •

    Difficult to add new tests • Tests are too tightly coupled to production code Maintenance overhead Challenges with TDD
  10. Test doubles • Like stunt doubles in movies • Objects

    we can use in our tests in place of real objects
  11. Test doubles • Like stunt doubles in movies • Objects

    we can use in our tests in place of real objects • e.g - dummies, stubs, fakes, mocks, spies
  12. Dummies Objects passed around, but not actually used. Often used

    to fill up constructor or method parameters
  13. Stubs • Objects that return predefined data • They usually

    don’t hold state/respond to other actions besides the one they are created for
  14. Stubs - example interface IUserRepository {...} ... class UserRepository(private val

    userDao: UserDao) : IUserRepository { /** * Executes query through the data access object */ override fun getUser(userId: Long): User { return userDao.findById(userId = userId) } }
  15. Stubs - example interface IUserRepository {...} ... class UserRepository(private val

    userDao: UserDao) : IUserRepository { /** * Executes query through the data access object */ override fun getUser(userId: Long): User { return userDao.findById(userId = userId) } } Real UserRepository class executes DAO queries
  16. Stubs - example interface IUserRepository {...} ... class UserRepositoryStub() :

    IUserRepository { ... /* * returns preconfigured user */ override fun getUser(userId: Long): User { return User(userId = 1, email = "[email protected]") } }
  17. Stubs - example interface IUserRepository {...} ... class UserRepositoryStub() :

    IUserRepository { ... /* * returns preconfigured user */ override fun getUser(userId: Long): User { return User(userId = 1, email = "[email protected]") } } Stubbed version of UserRepository returns hardcoded value
  18. Fakes • Similar to stubs, slightly more realistic • Contain

    working implementation, but different from real version
  19. Fakes • Similar to stubs, slightly more realistic • Contain

    working implementation, but different from real version • Typically models the behavior of the real class
  20. Fakes • Similar to stubs, slightly more realistic • Contain

    working implementation, but different from real version • Typically models the behavior of the real class • e.g in-memory-database
  21. Fakes - example interface UserDao {...} ... class FakeUserDao() :

    UserDao { private val users = mutableListOf<User>() override fun insert(user: User) { users.add(user) } override fun findById(userId: Long): User { return users.find { it.userId == userId } ?: throw Exception("user not found") } }
  22. interface UserDao {...} ... class FakeUserDao() : UserDao { private

    val users = mutableListOf<User>() override fun insert(user: User) { users.add(user) } override fun findById(userId: Long): User { return users.find { it.userId == userId } ?: throw Exception("user not found") } } Fakes - example
  23. interface UserDao {...} ... class FakeUserDao() : UserDao { private

    val users = mutableListOf<User>() override fun insert(user: User) { users.add(user) } override fun findById(userId: Long): User { return users.find { it.userId == userId } ?: throw Exception("user not found") } } Fake data source is a list instead of a database Fakes - example
  24. interface UserDao {...} ... class FakeUserDao() : UserDao { private

    val users = mutableListOf<User>() override fun insert(user: User) { users.add(user) } override fun findById(userId: Long): User { return users.find { it.userId == userId } ?: throw Exception("user not found") } } Fakes - example
  25. interface UserDao {...} ... class FakeUserDao() : UserDao { private

    val users = mutableListOf<User>() override fun insert(user: User) { users.add(user) } override fun findById(userId: Long): User { return users.find { it.userId == userId } ?: throw Exception("user not found") } } Fake Dao supports insert and find operations just like the real one Fakes - example
  26. Mocks • Objects pre-programmed with expected outputs for given inputs

    • Ability to record method calls and verify them
  27. Mocks • Objects pre-programmed with expected outputs for given inputs

    • Ability to record method calls and verify them • Throw exceptions if wanted method is not called
  28. Mocks • Objects pre-programmed with expected outputs for given inputs

    • Ability to record method calls and verify them • Throw exceptions if wanted method is not called • Different libraries depending on programming language
  29. Mocks - example class NotificationService( private val client: NotificationClient, private

    val groupRepository: IGroupRepository ) { fun post(notification: Notification, groupId: Long) { client.batchSend(notification, groupRepository.getMembers(groupId)) } }
  30. Mocks - example class NotificationService(...) {...} @Test fun sendBatchNotifications() {

    val client = mock<NotificationClient>() val groupRepo = mock<IGroupRepository>() whenever(groupRepo.getMembers(groupId = 5)).thenReturn(users) val service = NotificationService(client, groupRepo) service.post(notification, 5) verify(client).batchSend(notification, users) }
  31. Mocks - example class NotificationService(...) {...} @Test fun sendBatchNotifications() {

    val client = mock<NotificationClient>() val groupRepo = mock<IGroupRepository>() whenever(groupRepo.getMembers(groupId = 5)).thenReturn(users) val service = NotificationService(client, groupRepo) service.post(notification, 5) verify(client).batchSend(notification, users) } Create a mock
  32. Mocks - example class NotificationService(...) {...} @Test fun sendBatchNotifications() {

    val client = mock<NotificationClient>() val groupRepo = mock<IGroupRepository>() whenever(groupRepo.getMembers(groupId = 5)).thenReturn(users) val service = NotificationService(client, groupRepo) service.post(notification, 5) verify(client).batchSend(notification, users) } Set expectations for a mock
  33. Mocks - example class NotificationService(...) {...} @Test fun sendBatchNotifications() {

    val client = mock<NotificationClient>() val groupRepo = mock<IGroupRepository>() whenever(groupRepo.getMembers(groupId = 5)).thenReturn(users) val service = NotificationService(client, groupRepo) service.post(notification, 5) verify(client).batchSend(notification, users) } Verify mock interactions
  34. Spies • Hybrid between stubs and mocks • They are

    as real as stubs, but also have the ability to record interactions like mocks.
  35. Spies • Hybrid between stubs and mocks • They are

    as real as stubs, but also have the ability to record interactions like mocks.
  36. Maintainable tests: • Are reliable • Don’t change as often

    as the production code changes • Fail for only one reason
  37. Maintainable tests: • Are reliable • Don’t change as often

    as the production code changes • Fail for only one reason • Easy to modify & new tests are easy to add
  38. Also known as: Arrange - Act - Assert Or Given

    - When - Then Use a good test specification system
  39. Test behaviour, not implementation details Testing behavior means that: •

    For non-state changing methods (that return value), you only care about the output, not how the output was calculated.
  40. @Test fun testGetUserFromCacheIfUserExistsInCache() { val networkSource = mock<UserNetworkSource>() val cacheSource

    = mock<UserCacheSource>() whenever(cacheSource.userExistsInCache(5)).thenReturn(true) whenever(cacheSource.getUser(5)).thenReturn(User(5, "[email protected]")) } Set up test preconditions
  41. @Test fun testGetUserFromCacheIfUserExistsInCache() { val networkSource = mock<UserNetworkSource>() val cacheSource

    = mock<UserCacheSource>() whenever(cacheSource.userExistsInCache(5)).thenReturn(true) whenever(cacheSource.getUser(5)).thenReturn(User(5, "[email protected]")) val userRepo = UserRepo(cacheSource, networkSource) val user = userRepo.getUser(userId = 5) } Exercise the scenario under test
  42. @Test fun testGetUserFromCacheIfUserExistsInCache() { val networkSource = mock<UserNetworkSource>() val cacheSource

    = mock<UserCacheSource>() whenever(cacheSource.userExistsInCache(5)).thenReturn(true) whenever(cacheSource.getUser(5)).thenReturn(User(5, "[email protected]")) val userRepo = UserRepo(cacheSource, networkSource) val user = userRepo.getUser(userId = 5) verify(cacheSource).getUser(5) } Verify that we get user from cache
  43. @Test fun testGetUserFromCacheIfUserExistsInCache() { val networkSource = mock<UserNetworkSource>() val cacheSource

    = mock<UserCacheSource>() whenever(cacheSource.userExistsInCache(5)).thenReturn(true) whenever(cacheSource.getUser(5)).thenReturn(User(5, "[email protected]")) val userRepo = UserRepo(cacheSource, networkSource) val user = userRepo.getUser(userId = 5) verify(cacheSource).getUser(5) } This tests the implementation details Verify that we get user from cache
  44. @Test fun testGetUserFromCacheIfUserExistsInCache() { val networkSource = mock<UserNetworkSource>() val cacheSource

    = mock<UserCacheSource>() whenever(cacheSource.userExistsInCache(5)).thenReturn(true) whenever(cacheSource.getUser(5)).thenReturn(User(5, "[email protected]")) val userRepo = UserRepo(cacheSource, networkSource) val user = userRepo.getUser(userId = 5) assertEquals(5, user.userId) assertEquals("[email protected]", user.email) - verify(cacheSource).getUser(5) } Check that the user matches the one returned from the cache
  45. @Test fun testGetUserFromCacheIfUserExistsInCache() { val networkSource = mock<UserNetworkSource>() val cacheSource

    = mock<UserCacheSource>() whenever(cacheSource.userExistsInCache(5)).thenReturn(true) whenever(cacheSource.getUser(5)).thenReturn(User(5, "[email protected]")) val userRepo = UserRepo(cacheSource, networkSource) val user = userRepo.getUser(userId = 5) assertEquals(5, user.userId) assertEquals("[email protected]", user.email) }
  46. @Test fun testGetUserFromCacheIfUserExistsInCache() { val networkSource = mock<UserNetworkSource>() val cacheSource

    = mock<UserCacheSource>() whenever(cacheSource.userExistsInCache(5)).thenReturn(true) whenever(cacheSource.getUser(5)).thenReturn(User(5, "[email protected]")) val userRepo = UserRepo(cacheSource, networkSource) val user = userRepo.getUser(userId = 5) assertEquals(5, user.userId) assertEquals("[email protected]", user.email) } This tests general behavior when cache exists
  47. Test behaviour, not implementation details Testing behavior means that: •

    For non-state changing methods (that return value), you only care about the output, not how the output was calculated. • For state changing methods, verify that methods were called when using mocks
  48. Test behaviour, not implementation details Testing behavior means that: •

    For non-state changing methods (that return value), you only care about the output, not how the output was calculated. • For state changing methods, verify that methods were called • Use less of mocks, and more of stubs, fakes and other test doubles
  49. Test behaviour, not implementation details Testing behavior means that: •

    For non-state changing methods (that return value), you only care about the output, not how the output was calculated. • For state changing methods, verify that methods were called • Use less of mocks, and more of stubs, fakes and other test doubles
  50. @Test fun createNewUserAndSendWelcomeEmail() { // set up dependencies ... val

    userService = UserService(userRepository, emailClient) val newUser = userService.createUser("user", "[email protected]") assertEquals("user", newUser.username) assertEquals("[email protected]", newUser.email) assertEquals("", newUser.bio) assertEquals("", newUser.description) assertEquals(true, emailClient.sendWelcomeEmail()) } Avoid assertion roulette
  51. Avoid assertion roulette @Test fun createNewUserAndSendWelcomeEmail() { // set up

    dependencies ... val userService = UserService(userRepository, emailClient) val newUser = userService.createUser("user", "[email protected]") assertEquals("user", newUser.username) assertEquals("[email protected]", newUser.email) assertEquals("", newUser.bio) assertEquals("", newUser.description) assertEquals(true, emailClient.sendWelcomeEmail()) }
  52. @Test fun createNewUserAndSendWelcomeEmail() { // set up dependencies ... val

    userService = UserService(userRepository, emailClient) val newUser = userService.createUser("user", "[email protected]") assertEquals("user", newUser.username) assertEquals("[email protected]", newUser.email) assertEquals("", newUser.bio) assertEquals("", newUser.description) assertEquals(true, emailClient.sendWelcomeEmail()) } If test fails, it’s unclear why Avoid assertion roulette
  53. Fixing assertion roulette @Test fun createNewUserAndSendWelcomeEmail() { // set up

    dependencies ... val userService = UserService(userRepository, emailClient) val newUser = userService.createUser("user", "[email protected]") assertEquals("Created username doesn't match input username", "user", newUser.username) assertEquals("Created username doesn't match input username", "[email protected]", newUser.email) assertEquals("New user's bio is not empty", "", newUser.bio) assertEquals("New user's description is not empty", "", newUser.description) assertEquals("Did not send welcome email to new user",true, emailClient.sendWelcomeEmail()) } Simple fix: Add descriptive message to assertions
  54. Fixing assertion roulette @Test fun testCreateNewUser() {...} @Test fun testSendWelcomeEmailToNewUser()

    { // set up test ... assertEquals("Did not send welcome email to new user",true, emailClient.sendWelcomeEmail()) } Split the assertions into new test cases
  55. More tips • Use expressive test names • Avoid logic

    in your tests -> if/else, loops, etc.
  56. More tips • Use expressive test names • Avoid logic

    in your tests -> if/else, loops, etc. • Avoid abstractions in tests
  57. More tips • Use expressive test names • Avoid logic

    in your tests -> if/else, loops, etc. • Avoid abstractions in tests • Be generous with comments
  58. Recap • Benefits of TDD • Challenges with TDD •

    Mocks, spies, fakes, dummies, stubs - test doubles
  59. Recap • Benefits of TDD • Challenges with TDD •

    Mocks, spies, fakes, dummies, stubs - test doubles • Tips for writing maintainable tests