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

Writing Tests that Stand the Test of Time - droidcon Boston

Writing Tests that Stand the Test of Time - droidcon Boston

This talk was given at droidcon Boston 2019 (https://www.droidcon-boston.com/) in Boston, MA, United States.

Video: Not available yet.

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 situations where our tests are too coupled to our code and we have to change the tests every time we update the code. At this point, we start wondering if the tests are worth it.

If you have ever found yourself having to update your tests every time the behavior of a dependency of a class changes, then this talk is for you.

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

We will look at why we need tests in the first place, how writing tests is an investment that will save time in the future. We will also look at some causes of unmaintainable tests (for example using a wrong test double, testing implementation details and not general behavior) and how to overcome these problems. With the use of examples from everyday Android development, we will learn how to avoid brittle tests.

You will leave the talk having a clearer understanding of why tests are important and worth the time and ultimately be able to apply the tips to write robust and more maintainable tests.

9ab0b3b080e75e0c03a0c643333f8b93?s=128

Segun Famisa

April 08, 2019
Tweet

Transcript

  1. Writing tests that stand the test of time Segun Famisa

    Android GDE
  2. segunfamisa segunfamisa.com

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

    practice Writing maintainable tests Recap
  4. Introduction

  5. So, what’s Test Driven Development?

  6. 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 “
  7. 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
  8. 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
  9. How to do TDD?

  10. Red - Green - Refactor

  11. Red - Green - Refactor Write failing test

  12. Red - Green - Refactor Write failing test Write code,

    just enough to pass
  13. Red - Green - Refactor Write failing test Write code,

    just enough to pass Clean up
  14. Red - Green - Refactor Write failing test Write code,

    just enough to pass Clean up
  15. Why do we need tests?

  16. Why do we need tests? • Quick feedback about bugs/errors

  17. Why do we need tests? • Quick feedback about bugs/errors

    • Good code design
  18. Why do we need tests? • Quick feedback about bugs/errors

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

    • Good code design • Documentation for code behavior • Confident refactoring
  20. Challenges in practicing TDD

  21. Challenges in practicing TDD

  22. Challenges in practicing TDD Legacy code • Legacy code is

    difficult to test.
  23. Challenges in practicing TDD Time • Tests are code too,

    so they take time to write
  24. Challenges in practicing TDD Bad tests • Fragile and obscure

    tests defeat the purpose of TDD
  25. Challenges in practicing TDD Bad tests • Fragile and obscure

    tests defeat the purpose of TDD • “Bad tests” is worse than no tests - time and effort wasted without results
  26. Tools & concepts for writing maintainable tests

  27. Test doubles

  28. Test doubles Just like stunt doubles https://people.com/movies/actors-and-their-stunt-doubles-photos

  29. Test doubles - dummies

  30. Test doubles - dummies • Dummies are like placeholders. Just

    to fill in parameters.
  31. Test doubles - stubs

  32. Test doubles - stubs • Objects that return predefined data

    • They usually don’t hold state/respond to other actions besides the one they are created for
  33. Test doubles - stubs interface IUserRepository {...}

  34. Test doubles - stubs interface IUserRepository {...} ... class UserRepository(private

    val userDao: UserDao) : IUserRepository { override fun getUser(userId: Long): User { return userDao.findById(userId = userId) } }
  35. Test doubles - stubs interface IUserRepository {...} ... class UserRepository(private

    val userDao: UserDao) : IUserRepository { override fun getUser(userId: Long): User { return userDao.findById(userId = userId) } }
  36. Test doubles - stubs interface IUserRepository {...} ... class UserRepositoryStub()

    : IUserRepository { override fun getUser(userId: Long): User { return User(userId = 1, email = "sf@sf.com") } } Stub returns a preconfigured user
  37. Test doubles - fakes

  38. Test doubles - fakes • Similar to stubs, slightly more

    realistic • Contain working implementation, but different from real version • Typically models the behavior of the real class
  39. Test doubles - fakes interface UserDao {...}

  40. Test doubles - fakes interface UserDao {...} ... class FakeUserDao()

    : UserDao { 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") } }
  41. Test doubles - fakes interface UserDao {...} ... class FakeUserDao()

    : UserDao { 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 uses a list instead of a db
  42. Test doubles - fakes interface UserDao {...} ... class FakeUserDao()

    : UserDao { 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 the same operations
  43. Test doubles - mocks

  44. Test doubles - 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
  45. Test doubles - mocks @Test fun userShouldBeReturnedFromDao() { val dao:

    UserDao = mock() whenever(dao.getUser(userId = 5)).thenReturn(User(5, "sf@sf.com")) ... }
  46. Test doubles - mocks @Test fun userShouldBeReturnedFromDao() { val dao:

    UserDao = mock() whenever(dao.getUser(userId = 5)).thenReturn(User(5, "sf@sf.com")) ... } Mock pre-programmed with input/output
  47. Test doubles - mocks // SettingsPresenterTest.kt @Test fun clickingIconShouldOpenProfileScreen() {

    ... val view: SettingsContract.View = mock() val presenter = SettingsPresenter(view, userRepo) presenter.profileIconClicked() verify(view).openProfileScreen() }
  48. Test doubles - mocks Ability to verify interactions // SettingsPresenterTest.kt

    @Test fun clickingIconShouldOpenProfileScreen() { ... val view: SettingsContract.View = mock() val presenter = SettingsPresenter(view, userRepo) presenter.profileIconClicked() verify(view).openProfileScreen() }
  49. Test doubles - spies

  50. Test doubles - spies • Hybrid between stubs, fakes and

    mocks
  51. Test doubles - spies • Hybrid between stubs, fakes and

    mocks • They are as real as stubs, but also have the ability to record interactions like mocks.
  52. What are maintainable tests?

  53. None
  54. What are maintainable tests? Tests are maintainable when: • Old

    tests do not break often
  55. What are maintainable tests? Tests are maintainable when: • Old

    tests do not break often • Old tests are easy to update
  56. What are maintainable tests? Tests are maintainable when: • Old

    tests do not break often • Old tests are easy to update • Easy to add new tests
  57. Writing maintainable tests

  58. 1. Use a good test specification system Set up dependencies

    Exercise Verify
  59. 1. Use a good test specification system Also known as

    Arrange - Act - Assert Or Given - When - Then Set up dependencies Exercise Verify
  60. 1. Use a good test specification system @Test fun scenarioX()

    { // Given the dependencies/behavior // When we act on the scenario // Then assert that expected behavior happens }
  61. 2. Test behavior, not implementation details

  62. 2. Test behavior, not implementation details • For methods that

    return value, you should care only about the output, not how it was calculated.
  63. 2. Test behavior, not implementation details @Test fun `get user

    details from cache if available`() { ... val userRepo = UserRepository(cacheSource, networkSource) // given that a user exists in cache whenever(cacheSource.getUser(5)).thenReturn(User(5, "sf@sf.com")) // when we get user from repository val user = userRepo.getUser(userId = 5) // then verify that the cache source was called verify(cacheSource).getUser(5) }
  64. 2. Test behavior, not implementation details @Test fun `get user

    details from cache if available`() { ... val userRepo = UserRepository(cacheSource, networkSource) // given that a user exists in cache whenever(cacheSource.getUser(5)).thenReturn(User(5, "sf@sf.com")) // when we get user from repository val user = userRepo.getUser(userId = 5) // then verify that the cache source was called verify(cacheSource).getUser(5) } This tests implementation details
  65. 2. Test behavior, not implementation details @Test fun `get user

    details from cache if available`() { ... // given that a user exists in cache val cachedUser = User(5, "sf@sf.com") whenever(cacheSource.getUser(5)).thenReturn(cachedUser) // when we get user from repository val returnedUser = userRepo.getUser(userId = 5) // then verify that the returned user is the one from cache assertEquals(cachedUser, returnedUser) }
  66. 2. Test behavior, not implementation details @Test fun `get user

    details from cache if available`() { ... // given that a user exists in cache val cachedUser = User(5, "sf@sf.com") whenever(cacheSource.getUser(5)).thenReturn(cachedUser) // when we get user from repository val returnedUser = userRepo.getUser(userId = 5) // then verify that the returned user is the one from cache assertEquals(cachedUser, returnedUser) } This tests general behavior of this repository in this scenario.
  67. 2. Test behavior, not implementation details • For methods that

    return value, one should care only about the output, not how it was calculated. • For methods that do not return any value, verify interactions with dependencies
  68. 2. Test behavior, not implementation details • For methods that

    return value, one should care only about the output, not how it was calculated. • For methods that do not return any value, verify interactions with dependencies • Be careful about overusing mocks.
  69. 3. Assert/verify only one thing per test

  70. 3. Assert/verify only one thing per test In most cases,

    only one assert / verify should be done in each test.
  71. 3. Assert/verify only one thing per test In most cases,

    only one assert / verify should be done in each test. A test should fail for only 1 reason
  72. 3. Assert/verify only one thing per test @Test fun `enabling

    setting updates preference and sends tracking`() { ... // when user enables the setting viewModel.enableSetting() // then verify that we set preference verify(userPreference).enableSetting() // then verify that we send tracking verify(trackingUtils).trackUserEnabledSetting() }
  73. 3. Assert/verify only one thing per test @Test fun `enabling

    setting updates preference and sends tracking`() { ... // when user enables the setting viewModel.enableSetting() // then verify that we set preference verify(userPreference).enableSetting() // then verify that we send tracking verify(trackingUtils).trackUserEnabledSetting() }
  74. 3. Assert/verify only one thing per test @Test fun `enabling

    setting updates preference and sends tracking`() { ... // when user enables the setting viewModel.enableSetting() // then verify that we set preference verify(userPreference).enableSetting() // then verify that we send tracking verify(trackingUtils).trackUserEnabledSetting() } The use of “and” suggests that the test is testing more than one thing
  75. 3. Assert/verify only one thing per test @Test fun `enabling

    setting updates preference`() { ... // then verify that we set preference verify(userPreference).enableSetting() } @Test fun `enabling setting posts tracking`() { ... // then verify that we post tracking verify(trackingUtils).trackUserEnabledSetting() }
  76. 4. Use descriptive test names

  77. 4. Use descriptive test names From the test name, we

    should be able to tell why the test failed.
  78. 4. Use descriptive test names From the test name, we

    should be able to tell why the test failed. @Test fun `search field is updated correctly when user has search history`() { ... }
  79. 4. Use descriptive test names From the test name, we

    should be able to tell why the test failed. @Test fun `search field is updated correctly when user has search history`() { ... }
  80. 4. Use descriptive test names From the test name, we

    should be able to tell why the test failed. @Test fun `search field is updated correctly when user has search history`() { ... } @Test fun `search field is updated with last search when user has search history`() { ... }
  81. 4. Use descriptive test names From the test name, we

    should be able to tell why the test failed. @Test fun `search field is updated correctly when user has search history`() { ... } @Test fun `search field is updated with last search when user has search history`() { ... }
  82. 4. Use descriptive test names Kotlin allows us to use

    to write test function names with spaces @Test fun `welcome dialog should be shown on first log in`() { // test goes here }
  83. 4. Use descriptive test names JUnit 5 allows to specify

    a custom display name for the test @Test @DisplayName("welcome dialog should be shown on first log in") void showWelcomeDialogOnFirstLogin() { // test goes here }
  84. 5. More tips • Avoid logic in your tests ->

    if/else, loops, etc.
  85. 5. More tips • Avoid logic in your tests ->

    if/else, loops, etc. • Avoid abstractions in tests
  86. 5. More tips • Avoid logic in your tests ->

    if/else, loops, etc. • Avoid abstractions in tests • Be generous with comments
  87. 5. More tips • Avoid logic in your tests ->

    if/else, loops, etc. • Avoid abstractions in tests • Be generous with comments • Use parameterized tests
  88. Resources • https://martinfowler.com/articles/mocksArentStubs.html • http://xunitpatterns.com/ • https://testing.googleblog.com/search/label/TotT • https://mtlynch.io/good-developers-bad-tests/ •

    https://jeroenmols.com/blog/2018/12/06/fixthetest/
  89. Thank you! @segunfamisa