Slide 1

Slide 1 text

Writing tests that stand the test of time Segun Famisa Android GDE

Slide 2

Slide 2 text

segunfamisa segunfamisa.com

Slide 3

Slide 3 text

Outline Introduction to TDD Challenges with TDD Testing tools in practice Writing maintainable tests Recap

Slide 4

Slide 4 text

Introduction

Slide 5

Slide 5 text

So, what’s Test Driven Development?

Slide 6

Slide 6 text

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 “

Slide 7

Slide 7 text

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

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

How to do TDD?

Slide 10

Slide 10 text

Red - Green - Refactor

Slide 11

Slide 11 text

Red - Green - Refactor Write failing test

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

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

Slide 14

Slide 14 text

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

Slide 15

Slide 15 text

Why do we need tests?

Slide 16

Slide 16 text

Why do we need tests? ● Quick feedback about bugs/errors

Slide 17

Slide 17 text

Why do we need tests? ● Quick feedback about bugs/errors ● Good code design

Slide 18

Slide 18 text

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

Slide 19

Slide 19 text

Why do we need tests? ● Quick feedback about bugs/errors ● Good code design ● Documentation for code behavior ● Confident refactoring

Slide 20

Slide 20 text

Challenges in practicing TDD

Slide 21

Slide 21 text

Challenges in practicing TDD

Slide 22

Slide 22 text

Challenges in practicing TDD Legacy code ● Legacy code is difficult to test.

Slide 23

Slide 23 text

Challenges in practicing TDD Time ● Tests are code too, so they take time to write

Slide 24

Slide 24 text

Challenges in practicing TDD Bad tests ● Fragile and obscure tests defeat the purpose of TDD

Slide 25

Slide 25 text

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

Slide 26

Slide 26 text

Tools & concepts for writing maintainable tests

Slide 27

Slide 27 text

Test doubles

Slide 28

Slide 28 text

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

Slide 29

Slide 29 text

Test doubles - dummies

Slide 30

Slide 30 text

Test doubles - dummies ● Dummies are like placeholders. Just to fill in parameters.

Slide 31

Slide 31 text

Test doubles - stubs

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

Test doubles - stubs interface IUserRepository {...}

Slide 34

Slide 34 text

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

Slide 35

Slide 35 text

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

Slide 36

Slide 36 text

Test doubles - stubs interface IUserRepository {...} ... class UserRepositoryStub() : IUserRepository { override fun getUser(userId: Long): User { return User(userId = 1, email = "[email protected]") } } Stub returns a preconfigured user

Slide 37

Slide 37 text

Test doubles - fakes

Slide 38

Slide 38 text

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

Slide 39

Slide 39 text

Test doubles - fakes interface UserDao {...}

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

Test doubles - fakes interface UserDao {...} ... class FakeUserDao() : UserDao { val users = mutableListOf() 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

Slide 42

Slide 42 text

Test doubles - fakes interface UserDao {...} ... class FakeUserDao() : UserDao { val users = mutableListOf() 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

Slide 43

Slide 43 text

Test doubles - mocks

Slide 44

Slide 44 text

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

Slide 45

Slide 45 text

Test doubles - mocks @Test fun userShouldBeReturnedFromDao() { val dao: UserDao = mock() whenever(dao.getUser(userId = 5)).thenReturn(User(5, "[email protected]")) ... }

Slide 46

Slide 46 text

Test doubles - mocks @Test fun userShouldBeReturnedFromDao() { val dao: UserDao = mock() whenever(dao.getUser(userId = 5)).thenReturn(User(5, "[email protected]")) ... } Mock pre-programmed with input/output

Slide 47

Slide 47 text

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

Slide 48

Slide 48 text

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() }

Slide 49

Slide 49 text

Test doubles - spies

Slide 50

Slide 50 text

Test doubles - spies ● Hybrid between stubs, fakes and mocks

Slide 51

Slide 51 text

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.

Slide 52

Slide 52 text

What are maintainable tests?

Slide 53

Slide 53 text

No content

Slide 54

Slide 54 text

What are maintainable tests? Tests are maintainable when: ● Old tests do not break often

Slide 55

Slide 55 text

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

Slide 56

Slide 56 text

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

Slide 57

Slide 57 text

Writing maintainable tests

Slide 58

Slide 58 text

1. Use a good test specification system Set up dependencies Exercise Verify

Slide 59

Slide 59 text

1. Use a good test specification system Also known as Arrange - Act - Assert Or Given - When - Then Set up dependencies Exercise Verify

Slide 60

Slide 60 text

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 }

Slide 61

Slide 61 text

2. Test behavior, not implementation details

Slide 62

Slide 62 text

2. Test behavior, not implementation details ● For methods that return value, you should care only about the output, not how it was calculated.

Slide 63

Slide 63 text

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, "[email protected]")) // when we get user from repository val user = userRepo.getUser(userId = 5) // then verify that the cache source was called verify(cacheSource).getUser(5) }

Slide 64

Slide 64 text

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, "[email protected]")) // 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

Slide 65

Slide 65 text

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, "[email protected]") 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) }

Slide 66

Slide 66 text

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, "[email protected]") 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.

Slide 67

Slide 67 text

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

Slide 68

Slide 68 text

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.

Slide 69

Slide 69 text

3. Assert/verify only one thing per test

Slide 70

Slide 70 text

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

Slide 71

Slide 71 text

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

Slide 72

Slide 72 text

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() }

Slide 73

Slide 73 text

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() }

Slide 74

Slide 74 text

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

Slide 75

Slide 75 text

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() }

Slide 76

Slide 76 text

4. Use descriptive test names

Slide 77

Slide 77 text

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

Slide 78

Slide 78 text

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`() { ... }

Slide 79

Slide 79 text

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`() { ... }

Slide 80

Slide 80 text

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`() { ... }

Slide 81

Slide 81 text

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`() { ... }

Slide 82

Slide 82 text

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 }

Slide 83

Slide 83 text

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 }

Slide 84

Slide 84 text

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

Slide 85

Slide 85 text

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

Slide 86

Slide 86 text

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

Slide 87

Slide 87 text

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

Slide 88

Slide 88 text

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/

Slide 89

Slide 89 text

Thank you! @segunfamisa