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

Testing, how hard can it be? (Droidcon Lisbon 2022)

Testing, how hard can it be? (Droidcon Lisbon 2022)

When people start looking into the testing domain, very similar questions arise:
What to test? And more important, what not? What should I mock? What should I test with unit tests and what with Instrumentation? Do I need Robolectric? When? Do we need an in-memory database?
Let’s look at some examples.

Danny Preussler

April 25, 2022
Tweet

More Decks by Danny Preussler

Other Decks in Programming

Transcript

  1. 1 We lead what's next in music Testing how hard

    can it be? Danny Preussler, SoundCloud ¯\_(ツ)_/¯
  2. “how do you know something works when you don’t have

    test for it?” (Robert ‘Uncle Bob’ Martin)
  3. Today’s task - Write the Login feature for a Wear

    OS client - Authentication via phone OAuth
  4. @Composable fun LoginScreen() { val viewModel = hiltViewModel<LoginViewModel>() Box(modifier =

    Modifier.fillMaxSize()) { Button(onClick = { viewModel.login() }) { Text(viewModel.loginStatus.name) } } }
  5. @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)
  6. 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 } } }
  7. @Nested inner class `when logging in` { @Test fun `should

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

    `should set status to success`{ runTest { tested.login(this) } tested.loginStatus `should be` LOGGED_IN }
  9. @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 }
  10. class AuthOperations @Inject constructor( private val authClient: AuthClient, private val

    apiClient: ApiClient ) { suspend fun login(): AuthToken { val code = authClient.authenticate() return apiClient.registerCodeForToken(code) } }
  11. scope.launch { try { val token = operations.login() settings.authToken =

    token loginStatus = LOGGED_IN } catch(e: Exception) { e.printStackTrace() loginStatus = NOT_LOGGED_IN } }
  12. @Nested inner class `when logging in successful` { init {

    runTest { tested.login(this) } } @Test fun `should authenticate`() { verifyBlocking(authClient) { authenticate(any()) } } }
  13. class ApiClient @Inject constructor( private val apiService: ApiService ) {

    suspend fun registerCodeForToken(code: OAuthCode): AuthToken { return apiService.getToken( grantType = "authorization_code", code = code.value ).toToken() } }
  14. val authClient = mock<AuthClient> { onBlocking { authenticate(any()) } doReturn

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

    OAuthCode("123") } val service = mock<ApiService>{ onBlocking { getToken(any()) } doReturn TokenResponse("456") } val tested = LoginViewModel( AuthOperations(authClient, ApiClient(service)) ) )
  16. @Nested inner class `when logging in successful` { @Test fun

    `should authenticate`() { runTest { tested.login(this) } verifyBlocking(authClient) { authenticate(any()) } } }
  17. val authClient = mock<AuthClient> { onBlocking { authenticate(any()) } doReturn

    OAuthCode("123") } val service = mock<ApiService>{ onBlocking { getToken(any()) } doReturn TokenResponse("456") } val tested = LoginViewModel( AuthOperations(authClient, ApiClient(service)) ) )
  18. val authClient = object : OAuthClient { override suspend fun

    authenticate(clientId: ClientId) = OAuthCode("123") } val service = mock<ApiService>{ onBlocking { getToken(any()) } doReturn TokenResponse("456") } val tested = LoginViewModel( AuthOperations(authClient, ApiClient(service)) ) )
  19. scope.launch { try { operations.login() loginStatus = LOGGED_IN } catch(e:

    Exception) { e.printStackTrace() loginStatus = NOT_LOGGED_IN } }
  20. private class InMemorySettings: Settings { override var authToken: AuthToken? =

    null } val tested = LoginViewModel( InMemorySettings(), AuthOperations(authClient, ApiClient(service)) )
  21. @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") }
  22. class PersistingAuthOperations @Inject constructor( private val settings: Settings, private val

    authOperation: AuthOperations ){ suspend fun login(): AuthToken { return authOperation.login().also { settings.authToken = it } } }
  23. class SharedPreferenceTokenStorage( context: Application, val authPrefs: Lazy<SharedPreferences> = encrypted(context) )

    : TokenStorage { override var authToken: AuthToken? set(value) { authPrefs.value.edit { putString(KEY_TOKEN, value?.token) } }
  24. @RestrictTo(RestrictTo.Scope.TESTS) class SharedPreferencesStub : SharedPreferences { private val editor: EditorStub

    = EditorStub(this) // .. } private class EditorStub( val parent: SharedPreferences, val items: HashMap<String?, Any?> = hashMapOf()) : Editor
  25. internal class WearOAuthClient( private val context: Application, private val authClient:

    RemoteAuthClient = RemoteAuthClient.create(context) ) : OAuthClient {
  26. 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"))) } } }
  27. 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"))) } } }
  28. @RunWith(RobolectricTestRunner::class) @RobolectricNeededAsOf(RemoteAuthClient::class) class WearOAuthClientTest { val code = OAuthCode("789") val

    redirect = "http://test?code=$code" val successRequest = mock<RemoteAuthClient> { 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
  29. @Test fun `should extract the code when successful`() = runTest

    { tested = WearOAuthClient( application = RuntimeEnvironment.getApplication(), authClient = successRequest ) tested.authenticate() `should be equal to` code }
  30. @Test fun `should map token`() = runTest { response =

    tested.registerCodeForToken(Code(123)) response.token `should be equal to` "TH1S-1S-JUST-A-T3ST-T0KEN" }
  31. 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<HiltLoginViewModel>()
  32. @Test fun `should login and update status`() = runComposeTest {

    setContent { ScTheme { LoginScreen(viewModel) } }
  33. Tests should be coupled to the behavior of code and

    decoupled from the structure of code (Kent Beck)
  34. 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
  35. If it's worth building, it's worth testing If it's not

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

    worth testing, why are you wasting your time working on it?
  37. 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) } }
  38. class CoroutineTaskExecutor : BeforeEachCallback, AfterEachCallback { override fun beforeEach(context: ExtensionContext?)

    { Dispatchers.setMain(TestCoroutineDispatcher()) } override fun afterEach(context: ExtensionContext?) { Dispatchers.resetMain() } }
  39. @HiltViewModel internal class HiltLoginViewModel( private val authRepository: PersistingAuthRepository, private val

    scope: ViewModel.() -> CoroutineScope ) : LoginViewModel() { @Inject constructor( authRepository: PersistingAuthRepository ) : this(settings, authRepository, { viewModelScope })