đ€ 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 đ)
- đ€ Intro
- đ§© Ok et Android et les coroutines ?
- đ€ Les tests
- đ Un cas concret
- đ§ł Conclusion
đ§© 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'
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
.
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 çan'arrivejamais 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
đ 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 !
- Ne pas tester
- Faire un workaround
- Changer
String
enString?
- 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.
đ€Ź 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 đ„
đ§ł 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 Deferred
D :)