Image par https://pixabay.com/fr/users/geralt-9301
Image par https://pixabay.com/fr/users/geralt-9301

🎤 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 💸

Moi

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 .

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.

  • si oui, changer pour le nouveau, renvoyer une 204. -> 💚OK
  • sinon, renvoyer une 400 (bad request) -> 💔WrongPasswordException
  • au pire, péter et renvoyer une 500 -> 🔥 SomeBigMistakeException
    (je sais que dans la vraie vie ça n’arrive jamais parce que c’est testé par l’équipe qui gère le back 👻)

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

  • 💚ok
  • 💔bad_password
  • 🔥error
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

  • J’envoie un truc correct, ça doit me renvoyer ok.
  • Par contre, si j’envoie un truc incorrect, alors ça doit me renvoyer bad_password.
  • 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

😕#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à…

😕#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 !

  • Ne pas tester
  • Faire un workaround
  • Changer String en String?
  • Trouver autre chose ?

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.

🤬#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

Hubert Bonisseur de La Bath

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 Defered 🙂

Dernière modification: 21 novembre 2019