Slide 1

Slide 1 text

1 We lead what's next in music Testing how hard can it be? Danny Preussler, SoundCloud ¯\_(ツ)_/¯

Slide 2

Slide 2 text

2 We lead what's next in music

Slide 3

Slide 3 text

3 We lead what's next in music

Slide 4

Slide 4 text

4 We lead what's next in music

Slide 5

Slide 5 text

5 We lead what's next in music

Slide 6

Slide 6 text

6 We lead what's next in music

Slide 7

Slide 7 text

“Code without tests is bad code.” (Michael C. Feathers)

Slide 8

Slide 8 text

“any code without test is a legacy code.” (Michael C. Feathers)

Slide 9

Slide 9 text

“how do you know something works when you don’t have test for it?” (Robert ‘Uncle Bob’ Martin)

Slide 10

Slide 10 text

“Refactoring without good test coverage is changing shit” (Martin Fowler)

Slide 11

Slide 11 text

Android testing strategy Theory Unit tests Manual tests UI tests

Slide 12

Slide 12 text

Android testing strategy Reality Unit tests Manual tests UI tests Unit tests ¯\_(ツ)_/¯

Slide 13

Slide 13 text

Today’s task - Write the Login feature for a Wear OS client - Authentication via phone OAuth

Slide 14

Slide 14 text

@Composable fun LoginScreen() { val viewModel = hiltViewModel() Box(modifier = Modifier.fillMaxSize()) { Button(onClick = { viewModel.login() }) { Text(viewModel.loginStatus.name) } } }

Slide 15

Slide 15 text

@HiltViewModel class LoginViewModel @Inject constructor( private val operations: AuthOperations ) : ViewModel() { enum class LoginStatus { NOT_LOGGED_IN, LOGGED_IN, LOGGING_IN } var loginStatus: LoginStatus by mutableStateOf(NOT_LOGGED_IN)

Slide 16

Slide 16 text

A first test

Slide 17

Slide 17 text

@Test fun `initially its not logged in`() { tested.loginStatus `should be` NOT_LOGGED_IN }

Slide 18

Slide 18 text

fun login(scope: CoroutineScope = viewModelScope) { loginStatus = LOGGING_IN scope.launch { try { operations.login() loginStatus = LOGGED_IN } catch(e: Exception) { e.printStackTrace() loginStatus = NOT_LOGGED_IN } } }

Slide 19

Slide 19 text

Easy to test

Slide 20

Slide 20 text

@Nested inner class `when logging in` { @Test fun `should set loading status`() = runTest { tested.login(this) tested.loginStatus `should be` LOGGING_IN }

Slide 21

Slide 21 text

@Nested inner class `when logging in successful` { @Test fun `should set status to success`{ runTest { tested.login(this) } tested.loginStatus `should be` LOGGED_IN }

Slide 22

Slide 22 text

@Nested inner class `when logging in successful` { init { runTest { tested.login(this) } } @Test fun `should set status to success`{ tested.loginStatus `should be` LOGGED_IN }

Slide 23

Slide 23 text

authentication

Slide 24

Slide 24 text

class AuthOperations @Inject constructor( private val authClient: AuthClient, private val apiClient: ApiClient ) { suspend fun login(): AuthToken { val code = authClient.authenticate() return apiClient.registerCodeForToken(code) } }

Slide 25

Slide 25 text

val authClient = mock { onBlocking { authenticate(any()) } doReturn OAuthCode("123") }

Slide 26

Slide 26 text

scope.launch { try { val token = operations.login() settings.authToken = token loginStatus = LOGGED_IN } catch(e: Exception) { e.printStackTrace() loginStatus = NOT_LOGGED_IN } }

Slide 27

Slide 27 text

@Nested inner class `when logging in successful` { init { runTest { tested.login(this) } } @Test fun `should authenticate`() { verifyBlocking(authClient) { authenticate(any()) } } }

Slide 28

Slide 28 text

Lets write the ApiClient

Slide 29

Slide 29 text

interface ApiService { @FormUrlEncoded @POST("/oauth2/token") suspend fun getToken( @Field("code") code: String, @Field("grant_type") grantType: String, ): TokenResponse }

Slide 30

Slide 30 text

class ApiClient @Inject constructor( private val apiService: ApiService ) { suspend fun registerCodeForToken(code: OAuthCode): AuthToken { return apiService.getToken( grantType = "authorization_code", code = code.value ).toToken() } }

Slide 31

Slide 31 text

val authClient = mock { onBlocking { authenticate(any()) } doReturn OAuthCode("123") }

Slide 32

Slide 32 text

val authClient = mock { onBlocking { authenticate(any()) } doReturn OAuthCode("123") } val service = mock{ onBlocking { getToken(any()) } doReturn TokenResponse("456") } val tested = LoginViewModel( AuthOperations(authClient, ApiClient(service)) ) )

Slide 33

Slide 33 text

val authClient = mock { onBlocking { authenticate(any()) } doReturn OAuthCode("123") } val service = mock{ onBlocking { getToken(any()) } doReturn TokenResponse("456") } val tested = LoginViewModel( AuthOperations(authClient, ApiClient(service)) ) )

Slide 34

Slide 34 text

@Test fun `should authenticate`() { verifyBlocking(service) { token( code = "123", grantType = "authorization_code" ) )

Slide 35

Slide 35 text

@Nested inner class `when logging in successful` { @Test fun `should authenticate`() { runTest { tested.login(this) } verifyBlocking(authClient) { authenticate(any()) } } }

Slide 36

Slide 36 text

val authClient = mock { onBlocking { authenticate(any()) } doReturn OAuthCode("123") } val service = mock{ onBlocking { getToken(any()) } doReturn TokenResponse("456") } val tested = LoginViewModel( AuthOperations(authClient, ApiClient(service)) ) )

Slide 37

Slide 37 text

val authClient = object : OAuthClient { override suspend fun authenticate(clientId: ClientId) = OAuthCode("123") } val service = mock{ onBlocking { getToken(any()) } doReturn TokenResponse("456") } val tested = LoginViewModel( AuthOperations(authClient, ApiClient(service)) ) )

Slide 38

Slide 38 text

Use “the real thing” where possible

Slide 39

Slide 39 text

What about the token?

Slide 40

Slide 40 text

scope.launch { try { operations.login() loginStatus = LOGGED_IN } catch(e: Exception) { e.printStackTrace() loginStatus = NOT_LOGGED_IN } }

Slide 41

Slide 41 text

interface Settings { var authToken: AuthToken? }

Slide 42

Slide 42 text

private class InMemorySettings: Settings { override var authToken: AuthToken? = null } val tested = LoginViewModel( InMemorySettings(), AuthOperations(authClient, ApiClient(service)) )

Slide 43

Slide 43 text

@Nested inner class `when logging in successful` { init { runTest { tested.login(this) } } @Test fun `should save value`{ settings.authToken `should be equal to` AuthToken("456") }

Slide 44

Slide 44 text

I don’t like this

Slide 45

Slide 45 text

class PersistingAuthOperations @Inject constructor( private val settings: Settings, private val authOperation: AuthOperations ){ suspend fun login(): AuthToken { return authOperation.login().also { settings.authToken = it } } }

Slide 46

Slide 46 text

val tested = LoginViewModel( PersistingAuthOperations( settings, AuthOperations(authClient, ApiClient(service)) ) ) ¯\_(ツ)_/¯

Slide 47

Slide 47 text

Refactoring should not lead to test changes

Slide 48

Slide 48 text

Let’s save the token

Slide 49

Slide 49 text

interface TokenStorage { var authToken: AuthToken? } interface Settings : TokenStorage

Slide 50

Slide 50 text

class SharedPreferenceTokenStorage( context: Application, val authPrefs: Lazy = encrypted(context) ) : TokenStorage { override var authToken: AuthToken? set(value) { authPrefs.value.edit { putString(KEY_TOKEN, value?.token) } }

Slide 51

Slide 51 text

@RestrictTo(RestrictTo.Scope.TESTS) class SharedPreferencesStub : SharedPreferences { private val editor: EditorStub = EditorStub(this) // .. } private class EditorStub( val parent: SharedPreferences, val items: HashMap = hashMapOf()) : Editor

Slide 52

Slide 52 text

Prefer Stubs over mocks, reuse them!

Slide 53

Slide 53 text

The auth client

Slide 54

Slide 54 text

internal class WearOAuthClient( private val context: Application, private val authClient: RemoteAuthClient = RemoteAuthClient.create(context) ) : OAuthClient {

Slide 55

Slide 55 text

override suspend fun authenticate(): OAuthCode = suspendCoroutine { continuation -> authClient.sendAuthorizationRequest( // .. object : RemoteAuthClient.Callback() { override fun onAuthorizationResponse(response: OAuthResponse) { continuation.resumeWith(success(OAuthCode(response.code))) } override fun onAuthorizationError(errorCode: Int) { continuation.resumeWith( failure(IOException("failed: $errorCode"))) } } }

Slide 56

Slide 56 text

override suspend fun authenticate(): OAuthCode = suspendCoroutine { continuation -> authClient.sendAuthorizationRequest( // .. object : RemoteAuthClient.Callback() { override fun onAuthorizationResponse(response: OAuthResponse) { continuation.resumeWith(success(OAuthCode(response.code))) } override fun onAuthorizationError(errorCode: Int) { continuation.resumeWith( failure(IOException("failed: $errorCode"))) } } }

Slide 57

Slide 57 text

@RunWith(RobolectricTestRunner::class) @RobolectricNeededAsOf(RemoteAuthClient::class) class WearOAuthClientTest { val code = OAuthCode("789") val redirect = "http://test?code=$code" val successRequest = mock { on { sendAuthorizationRequest(any(), any(), any()) } doAnswer { val request = it.arguments[0] as OAuthRequest val response = Builder().setResponseUrl(redirect).build() (it.arguments[2] as Callback).onAuthorizationResponse(response) } } o_o

Slide 58

Slide 58 text

@Test fun `should extract the code when successful`() = runTest { tested = WearOAuthClient( application = RuntimeEnvironment.getApplication(), authClient = successRequest ) tested.authenticate() `should be equal to` code }

Slide 59

Slide 59 text

For external dependencies Use Mocks and Robolectric

Slide 60

Slide 60 text

Back to api

Slide 61

Slide 61 text

Refactorings: ● AuthOperations -> AuthRepository ● ApiClient used in new ApiDataSource No test change ¯\_(ツ)_/¯

Slide 62

Slide 62 text

internal class ApiAuthDataSource @Inject constructor( val api: Lazy ) : AuthDataSource {

Slide 63

Slide 63 text

val service = JsonReadingAuthService( "/api/public_api_token.json", AppModule.providesMoshi() ) val tested = ApiAuthDataSource { service }

Slide 64

Slide 64 text

@Test fun `should map token`() = runTest { response = tested.registerCodeForToken(Code(123)) response.token `should be equal to` "TH1S-1S-JUST-A-T3ST-T0KEN" }

Slide 65

Slide 65 text

Test the JSON from your backend

Slide 66

Slide 66 text

Let’s test the UI

Slide 67

Slide 67 text

class LoginViewModelStub : LoginViewModel() { override fun login() { loginStatus = LOGGED_IN } }

Slide 68

Slide 68 text

abstract class LoginViewModel : ViewModel() { enum class LoginStatus { LOGGED_IN, LOGGING_IN, NOT_LOGGED_IN } var loginStatus: LoginStatus by mutableStateOf(NOT_LOGGED_IN) abstract fun login() // .. viewModel: LoginViewModel = hiltViewModel()

Slide 69

Slide 69 text

@RunWith(RobolectricTestRunner::class) @Config(instrumentedPackages = ["androidx.loader.content"]) class LoginScreenTest { @get:Rule val composeTestRule = createComposeRule() val viewModel = LoginViewModelStub()

Slide 70

Slide 70 text

@Test fun `should login and update status`() = runComposeTest { setContent { ScTheme { LoginScreen(viewModel) } }

Slide 71

Slide 71 text

onNodeWithText(LOGGED_IN.name).assertDoesNotExist() onNodeWithText(NOT_LOGGED_IN.name).performClick() onNodeWithText(LOGGED_IN.name).assertExists()

Slide 72

Slide 72 text

Prefer Robolectric over Device for UI tests

Slide 73

Slide 73 text

What did we write? LoginScreen LoginViewModel LoginScreenTest LoginViewModelTest LoginRepository ApiDataSourceTest ApiDataSource WearOAuthClientTest SharedPreferenceTokenStorageTest

Slide 74

Slide 74 text

What did we write? LoginScreen LoginViewModel LoginScreenTest LoginViewModelTest LoginRepository WearOAuthClientTest SharedPreferenceTokenStorageTest ApiDataSource

Slide 75

Slide 75 text

This is not a unit test! (╯°□°)╯︵ ┻━┻

Slide 76

Slide 76 text

This is not a unit test!

Slide 77

Slide 77 text

The way most developers write their tests Test1 Test2 Class1 Class2 Class3 Test3

Slide 78

Slide 78 text

The way some developers write their tests Test1 Class1 Class2 Class3

Slide 79

Slide 79 text

The way TDD developers write their tests Test1 Class1

Slide 80

Slide 80 text

The way TDD developers write their tests Test1 Class1 Class2 Class3

Slide 81

Slide 81 text

Solid tests: A true story Test1 ViewModel Repository API

Slide 82

Slide 82 text

Solid tests: A true story Test1 ViewModel Repository API Store Reducer SideEffects

Slide 83

Slide 83 text

WHAT your code does is stable HOW your code does it is unstable

Slide 84

Slide 84 text

Solid tests: A true story Test1 ViewModel Repository API Store Reducer SideEffects

Slide 85

Slide 85 text

Tests should be coupled to the behavior of code and decoupled from the structure of code (Kent Beck)

Slide 86

Slide 86 text

True facts Fast and stable Flaky and slow

Slide 87

Slide 87 text

Can’t avoid flakyness at top Fast and stable Flaky and slow Your decision

Slide 88

Slide 88 text

Android testing strategy The real deal Junit (5) End to end Mocked endpoints Robolectric

Slide 89

Slide 89 text

The good pyramid ● Prefer screen tests over end to end tests ● Prefer mocked endpoint over api calls ● Prefer mocks/stubs over mock web servers ● Prefer Robolectric over Device tests ● Prefer JVM tests over Robolectric ● Do you really need that in memory database? Performance and flakyness

Slide 90

Slide 90 text

"Test only if you would want it to work.” Kent Beck

Slide 91

Slide 91 text

If it's worth building, it's worth testing If it's not worth testing, why are you wasting your time working on it?

Slide 92

Slide 92 text

If it's worth building, it's worth testing If it's not worth testing, why are you wasting your time working on it?

Slide 93

Slide 93 text

There is some ugliness in the test

Slide 94

Slide 94 text

val code = OAuthCode("789")

Slide 95

Slide 95 text

private var counter = 1 object AuthTokenFixture { operator fun invoke(token: String = "${counter++}"): AuthToken { return AuthToken(token) } } object OAuthCodeFixture { operator fun invoke(code: String = "${counter++}"): OAuthCode { return OAuthCode(code) } }

Slide 96

Slide 96 text

val code = OAuthCodeFixture()

Slide 97

Slide 97 text

/module /src /main /testFixtures / Fixtures.java

Slide 98

Slide 98 text

Another Tip Avoiding Dispatchers.setMain

Slide 99

Slide 99 text

class CoroutineTaskExecutor : BeforeEachCallback, AfterEachCallback { override fun beforeEach(context: ExtensionContext?) { Dispatchers.setMain(TestCoroutineDispatcher()) } override fun afterEach(context: ExtensionContext?) { Dispatchers.resetMain() } }

Slide 100

Slide 100 text

@HiltViewModel internal class HiltLoginViewModel( private val authRepository: PersistingAuthRepository, private val scope: ViewModel.() -> CoroutineScope ) : LoginViewModel() { @Inject constructor( authRepository: PersistingAuthRepository ) : this(settings, authRepository, { viewModelScope })

Slide 101

Slide 101 text

101 We lead what's next in music Thank You @PreusslerBerlin