đŸŽ€ Intro

S’il y a bien une chose que je dis souvent, c’est:

Les tests, tout le monde en veut, personne ne veut les payer 💾

Ce qui est (souvent) vrai en soi. Je ne dis pas que c’est bien ! Ni que c’est une gĂ©nĂ©ralitĂ©. ⚠

Certains diront, “oui, heuu, non, nous on fait du TDD, toussa” (avec la voix de Coluche).

Tant mieux pour toi Jean-Jacques.

Dans la vraie vie, tout le monde est convaincu que c’est utile, mais trĂšs peu le font quand mĂȘme. Par flemme, par coĂ»ts, par mĂ©connaissance, pour plein de mauvaises raisons (et moi le premier 😞)

đŸ§© Ok et Android et les coroutines ?

DĂ©jĂ , si tu penses que dans cet article tu vas avoir toutes les explications sur comment tester ses coroutines, pas de bol, c’est pas le cas. đŸ˜¶

En revanche, y a quand mĂȘme des trucs bien relous Ă  tester aujourd’hui, et c’est de ça dont je vais parler ici.

Pourquoi ?

Parce que j’ai dĂ» jouer Ă  Indiana pendant 1/2 journĂ©e sur les internets pour trouver comment faire. đŸ€Ż

Et comme je n’ai clairement pas envie que ça se reproduise, je vais rĂ©sumer tout ça ici.

đŸ€™ Les tests

⛔ OĂč ?

Admettons qu’on veuille tester plusieurs choses, dĂ©jĂ  il y a le oĂč.

Sur Android, si on dĂ©coupe bien son application, on a certaines couches qui vont interagir avec le systĂšme, et d’autres pas du tout.

Dans le cas oĂč on interagit avec le systĂšme (par exemple les Activity, les Fragments, les ViewModels) on va avoir tendance Ă  mettre nos tests dans le dossier androidTest.

Dans l’autre cas, quand aucun import ni usage de choses spĂ©cifiques Ă  Android (oui Context, je parle de toi), n’est fait, c’est du java kotlin pur, les tests seront plus dans un dossier test.

D’ailleurs, ce code peut mĂȘme ĂȘtre extrait dans un module Ă  part mais c’est une autre histoire


L’un ne remplace pas l’autre, ils sont complĂ©mentaires.

Au passage, c’est pour ça que dans votre projet, quand vous lancez votre campagne de test avec gradle pour les tests “Android” c’est connectedAndroidTest qu’il faut lancer, mais pour les tests “Unitaires” c’est test.

📩 Avec quoi ?

Comme partout dans le dev, on a plein d’outils Ă  disposition pour faire des tests, gĂ©nĂ©ralement c’est mĂȘme dĂ©jĂ  inclus dans le projet.

Je parle des librairies oui. 🎉

Pour ce qui est des tests Android ils sont généralement inclus dans le build.gradle comme ça

androidTestImplementation 'androidx.test:rules:1.1.1'
androidTestImplementation 'androidx.test:runner:1.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'

On remarquera le androidTestImplementation ça permet de n’embarquer la librairie que dans le cas de tests en mode androidTest

Et pour les tests Unitaires

testImplementation 'junit:junit:4.12'
testImplementation 'androidx.arch.core:core-testing:2.0.0'
Je fais une parenthÚse ici, Jetbrains a publié sa librairie d'extensions de tests pour les coroutines https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/.
Mais j'avoue ne pas l'avoir encore testĂ©. Je ferais peut-ĂȘtre une update.

C’est tout ?

En vrai j’aime bien en rajouter une dans les tests Unitaires

testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.1.0"

On va voir pourquoi aprĂšs.

😈 Un cas concret

Prenons un exemple simple

đŸ€·â€â™‚ïž Le usecase

J’ai 1 objet que je veux tester, c’est un UseCase qui sert à changer son mot de passe dans l’application. On va l’appeler ChangePasswordUseCase.

🚩 pour garder une certaine simplicitĂ©, je passe dans l'exemple l'implementation d'interface etc

ChangePasswordUseCase ne dispose que d’une mĂ©thode, changePassword qui prend en paramĂštre l’ancien mot de passe, le nouveau mot de passe.

Il envoie tout ça, disons dans une API (on va raccourcir ici en provider, parce que ça pourrait tout Ă  fait ĂȘtre autre chose) qui va vĂ©rifier si l’ancien mot de passe est valable.

Et donc en fonction du retour de ce provider, le but de notre ChangePasswordUseCase c’est de renvoyer un truc simple à la couche d’au dessus (genre le ViewModel).

Par exemple

class ChangePasswordUseCase(private val provider: Repository) {
    
  suspend fun changePassword(oldPassword: String, newPassword: String): ChangePasswordState {
        return try {
            provider.updatePassword(oldPassword, newPassword).await()
            ChangePasswordState.OK
        }  catch (e: WrongPasswordException) {
            ChangePasswordState.BAD_PASSWORD
        } catch (e: SomeBigMistakeException) {
            ChangePasswordState.ERROR
        }
    }
}

Et nos tests, ça sera donc

  1. J’envoie un truc correct, ça doit me renvoyer ok.
  2. Par contre, si j’envoie un truc incorrect, alors ça doit me renvoyer bad_password.
  3. Et enfin, si un truc horrible se passe, alors ça ne lance pas une exception, ça me renvoie error.

On notera que provider.updatePassword(oldPassword, newPassword) est une fonction qui renvoie un Deferred<Unit>. C’est pour ça qu’on a un .await(), effectivement, comme on attend le retour, on ne veut pas bloquer notre utilisateur (d’oĂč la coroutine).

đŸ€ł Les tests

On commence par créer un fichier dans test, à tout hasard, ChangePasswordUseCaseTest qui dispose de 3 méthodes.

class ChangePasswordUseCaseTest {

    @Test
    fun `change password works if provider returns unit`() {
    }

    @Test
    fun `change password fails with bad password if wrong password is given`() {
    }

    @Test
    fun `change password fails with error if provider returns error`() {
    }
}

Concentrons nous sur la premiÚre déjà.

@Test
fun `change password works if provider returns unit`() {

}

On va faire comme partout, on va Mocker notre provider pour qu’il rĂ©agisse comme on le souhaite, cela va permettre de tester notre UseCase.

Pour faire ça, il y a une librairie sympa qui s’appelle Mockito.

Admettons qu’on l’intùgre à notre projet, on pourrait imaginer un mock comme cela

😕 N°1 Suspend function changePassword should be called only from a coroutine or another suspend function

On commence par créer notre mock.

@Test
fun `change password works if provider returns unit`() {
    val mock = Mockito.mock(Repository::class.java)
}

Et ensuite, on mock la rĂ©ponse qu’on dĂ©sire pour tester la rĂ©action de notre UseCase, dans notre cas, peu importe ce qu’on envoie, on veut une rĂ©ponse qui fonctionne.

Et ensuite on créé notre UseCase avec notre mock.

@Test
fun `change password works if api returns unit`() {
    val mock = Mockito.mock(Repository::class.java) 
    Mockito
            .`when`(mock.updatePassword(Mockito.any(), Mockito.any()))
            .thenReturn(Unit.toDeferred())
    val usecase = ChangePasswordUseCase(mock)
}

(Le secret du .toDeferred() -> fun <T> T.toDeferred() = CompletableDeferred(this))

Et lĂ  on n’a plus qu’à Ă©crire notre test, Ă  savoir, si je t’envoie un truc, en sortie je veux un OK

@Test
fun change password works if api returns unit() {
     val mock = Mockito.mock(Repository::class.java) 
     Mockito
             .when(mock.updatePassword(Mockito.any(), Mockito.any()))
             .thenReturn(Unit.toDeferred())
     val usecase = ChangePasswordUseCase(mock)
     val result = usecase.changePassword("", "")
     assertEquals(ChangePasswordState.OK, result)
 }

Allez hop, le changePassword en rouge, Android Studio nous affiche un joli warning en mode Suspend function ‘changePassword’ should be called only from a coroutine or another suspend function.

Heureusement ce problÚme-là il est plutÎt simple à régler


On rajoute suspend devant ? HĂ© bah nan ! ça ne fonctionne pas (en vrai c’est logique). C’est lĂ  oĂč la librairie d’extensions de kotlin est utile. En attendant de l’utiliser, on va faire Ă  l’ancienne: on va juste rajouter un runBlocking.

En résumé, on a donc:

@Test
fun `change password works if api returns unit`() {
    val mock = Mockito.mock(Repository::class.java) 
    Mockito
            .`when`(mock.updatePassword(Mockito.any(), Mockito.any()))
            .thenReturn(Unit.toDeferred())
    val usecase = ChangePasswordUseCase(mock)
    val result = runBlocking {
        return@runBlocking usecase.changePassword("", "")
    }
    assertEquals(ChangePasswordState.OK, result)
}

On compile, on lance notre test, et là


😕 N°2 java.lang.IllegalStateException: Mockito.any() must not be null


 c’est le drame đŸ’„đŸ§š

java.lang.IllegalStateException: Mockito.any() must not be null

C’est mĂȘme pas comme si on avait une erreur, ou que notre test Ă©tait KO, c’est carrĂ©ment Mockito qui nous lĂąche un beau đŸ’©dans les retours


YOU WERE THE CHOSEN ONE !

On fait moultes recherches sur internet pour finir sur ça: https://stackoverflow.com/questions/49148801/mock-object-in-android-unit-test-with-kotlin-any-gives-null

TL;DR; Mockito.any() ça retourne null.

Et kotlin, bah les null, il n’aime pas ça. (Et oui ! On a mis String et pas String?).

Ouf, une multitude de solutions s’offrent à nous !

Fort heureusement, une libraire permet de rendre tout ça plus facile (blah blah blah, oui, encore une librairie).

https://github.com/nhaarman/mockito-kotlin

A vous de voir si vous l’utilisez ou si vous faites une des autres options, moi j’ai fait mon choix.

@Test
fun `change password works if api returns unit`() {
    val pMock = mock<Repository> {
            onBlocking { updatePassword(any(), any()) } 
            doReturn Unit.toDeferred()
    }
    
    val usecase = ChangePasswordUseCase(pMock)
    val result = runBlocking {
        return@runBlocking usecase.changePassword("", "")
    }
    assertEquals(ChangePasswordState.OK, result)
}

Sans rentrer dans les dĂ©tails, le any() utilisĂ© n’est plus celui de Mockito mais bien celui de mockitokotlin2. Et il est compliant.

Et lĂ  on a un TU qui passe đŸŸ

On va donc passer à notre 2ùme cas de test, celui d’une erreur en cas de mauvais paramùtres.

đŸ€Ź N°3 java.lang.IllegalStateException: Mockito.any() must not be null

Notre test est plutÎt simple, notre mock va renvoyer une exception, notre usecase est censé la catcher et renvoyer BAD_PASSWORD.

On modifie notre code d’origine et maintenant, on Ă©crit quelque chose comme ça:

@Test
fun `change password fails with bad password if password is wrong`() {
    // Given
    val repository = mock<Repository.Network> {
        onBlocking { updatePassword(any(), any()) } 
        doThrow WrongPasswordException("bad password")
    }
    cpuc = ChangePasswordUseCase(repository)

    val result = runBlocking {
        return@runBlocking cpuc.changePassword("", "")
    }
    assertEquals(ChangePasswordState.BAD_PASSWORD, result)
}

Finalement, on a juste remplacé le doReturn par un doThrow et le OK par un BAD_PASSWORD.

On démarre le test et

org.mockito.exceptions.base.MockitoException: 
 Checked exception is invalid for this method!
 Invalid: fr.cim.izymobile.model.repository.WrongPasswordException: bad password

WTF Man ?

Mockito nous dit gentillement, qu’il s’est pris une exception donc il a pas pu continuer le test. Et nous invite Ă  dire qu’il faut checker l’exception (enfin pas vraiment, mais il dit qu’elle ne correspond pas aux exceptions checkĂ©es).

Premier reflexe ? Bah on va checker l’exception

@Test(expected = WrongPasswordException::class)
fun `change password fails with bad password if password is wrong`() {
    // Given
    val repository = mock<Repository.Network> {
        onBlocking { updatePassword(any(), any()) } 
        doThrow WrongPasswordException("bad password")
    }
    cpuc = ChangePasswordUseCase(repository)

    val result = runBlocking {
        return@runBlocking cpuc.changePassword("", "")
    }
    assertEquals(ChangePasswordState.BAD_PASSWORD, result)
}

Et hop

java.lang.Exception: Unexpected exception, expected<fr.cim.izymobile.model.repository.WrongPasswordException> but was<org.mockito.exceptions.base.MockitoException>

Nous voilĂ  de retour sur les internets đŸ’»đŸ›  On passe un bon moment non ? Vous commencez Ă  comprendre pourquoi j’écris l’article ?

Je vous passe les dĂ©tails parce que en fait, ça semble Ă©vident mais en gros, ce mode de fonctionnement n’est pas pour dire laisse passer les WrongPasswordException mais, mon test DOIT renvoyer une WrongPasswordException
 Ce qui n’est pas du tout le cas


Retour au point de départ donc ?

En fait non, il y a quand mĂȘme pas mal de lecture qui nous donne des pistes https://blog.fuzzylimes.net/coding/TIL-mocking-exception-throws/

Mais surtout https://github.com/mockito/mockito/issues/1166

Et cette derniĂšre issue (toujours ouverte au moment oĂč j’écris ces lignes), elle nous fait faire ça

@Test(expected = WrongPasswordException::class)
fun `change password fails with bad password if password is wrong`() {
    // Given
    val repository = mock<Repository.Network> { }
    doAnswer { throw WrongPasswordException("bad parameter") }
        .`when`(repository)
        .updatePassword(any(), any())

    cpuc = ChangePasswordUseCase(repository)

    val result = runBlocking {
        return@runBlocking cpuc.changePassword("", "")
    }
    assertEquals(ChangePasswordState.BAD_PASSWORD, result)
}

On crĂ©e notre mock et ensuite on fait en sorte qu’il rĂ©ponde (doAnswer) et pas crash (doThrow). Sauf que dans le doAnswer, on fait un throw.

Habile

Et lĂ  miracle, le test passe đŸ„‚

Nos tests à la fin 😄

🧳 Conclusion

Au final ?

DĂ©jĂ , Ă©crire des tests, ça devrait ĂȘtre simple. Mais dans la vraie vie, ça ne l’est pas tant que ça.

Je sais que, dans beaucoup de cas, c’est aussi dĂ» Ă  une mauvaise/non lecture de la documentation. Je plaide coupable, j’aime bien tester les choses et lire aprĂšs et parfois, ça ne se passe pas bien (doThrow, salut !).

Quand ce n’est pas imposĂ© par l’équipe/le client/you_name_it, on a tendance Ă  les zapper et si c’est pour se payer 1/2j de recherche Ă  chaque fois qu’on essaye d’en faire, je comprends pourquoi il y en a si peu 😱.

Heureusement, avec l’impulsion de choses bien comme le software craftmanship et les prises de conscience, on en fait de plus en plus. Certains pionniers nous rendent la vie facile (genre les articles que j’ai citĂ©s).

De mon cĂŽtĂ© je vais m’auto-forcer le plus possible Ă  en faire rĂ©guliĂšrement sur mes projets, c’est important. Le truc c’est que je switch rĂ©guliĂšrement de techno, et dans certains cas, ce sont des ruses que je risque de perdre de vue. Je voulais donc pouvoir les retrouver facilement.

Si ça vous aide, tant mieux, moi je sais que ça sera le cas.

D’ailleurs, pour enfoncer le clou, on n’aurait pas eu tous ces problĂšmes si la fonction updatePassword Ă©tait suspend plutĂŽt que renvoyer un DeferredD :)