From 163bb8cd10ddc4cc3427517e89d9ce1121b95db7 Mon Sep 17 00:00:00 2001 From: Gergely Hegedus Date: Wed, 26 Jan 2022 20:28:43 +0200 Subject: [PATCH] Issue#13 First part of the Robolectric Documentation --- README.md | 2 +- .../CodeKataFavouriteContentLocalStorage.kt | 51 ++++ .../FavouriteContentLocalStorageImplTest.kt | 26 +- codekata/robolectric.instructionset.md | 237 ++++++++++++++++++ codekata/storage.instructionset | 1 - .../login/CodeKataSecondLoginUseCaseTest.kt | 8 +- 6 files changed, 314 insertions(+), 11 deletions(-) create mode 100644 app/src/robolectricTest/java/org/fnives/test/showcase/storage/favourite/CodeKataFavouriteContentLocalStorage.kt rename app/src/robolectricTest/java/org/fnives/test/showcase/{testutils => storage}/favourite/FavouriteContentLocalStorageImplTest.kt (79%) create mode 100644 codekata/robolectric.instructionset.md delete mode 100644 codekata/storage.instructionset diff --git a/README.md b/README.md index c04476c..1655a23 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ We will also see how to test with LiveData. We will introduce Rules, aka easy to reuse "Before" and "After" components. #### App Robolectric Unit Tests. -Open the [app storage unit tests instruction set](./codekata/storage.instructionset). +Open the [app robolectric unit tests instruction set](./codekata/robolectric.instructionset.md). In this section we will see how to test component depending on context such as Room database. diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/storage/favourite/CodeKataFavouriteContentLocalStorage.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/storage/favourite/CodeKataFavouriteContentLocalStorage.kt new file mode 100644 index 0000000..f84bf89 --- /dev/null +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/storage/favourite/CodeKataFavouriteContentLocalStorage.kt @@ -0,0 +1,51 @@ +package org.fnives.test.showcase.storage.favourite + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.jupiter.api.Disabled + +@Disabled("CodeKata") +@OptIn(ExperimentalCoroutinesApi::class) +class CodeKataFavouriteContentLocalStorage { + + @Before + fun setUp() { + } + + @After + fun tearDown() { + } + + /** GIVEN just created database WHEN querying THEN empty list is returned */ + @Test + fun atTheStartOurDatabaseIsEmpty() = runBlocking { + } + + /** GIVEN content_id WHEN added to Favourite THEN it can be read out */ + @Test + fun addingContentIdToFavouriteCanBeLaterReadOut() { + } + + /** GIVEN content_id added WHEN removed to Favourite THEN it no longer can be read out */ + @Test + fun contentIdAddedThenRemovedCanNoLongerBeReadOut() { + } + + /** GIVEN empty database WHILE observing content WHEN favourite added THEN change is emitted */ + @Test + fun addingFavouriteUpdatesExistingObservers() { + } + + /** GIVEN non empty database WHILE observing content WHEN favourite removed THEN change is emitted */ + @Test + fun removingFavouriteUpdatesExistingObservers() { + } + + /** GIVEN an observed WHEN adding and removing from it THEN we only get the expected amount of updates */ + @Test + fun noUnexpectedUpdates() { + } +} diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/favourite/FavouriteContentLocalStorageImplTest.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/storage/favourite/FavouriteContentLocalStorageImplTest.kt similarity index 79% rename from app/src/robolectricTest/java/org/fnives/test/showcase/testutils/favourite/FavouriteContentLocalStorageImplTest.kt rename to app/src/robolectricTest/java/org/fnives/test/showcase/storage/favourite/FavouriteContentLocalStorageImplTest.kt index 2c77b78..479e5f6 100644 --- a/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/favourite/FavouriteContentLocalStorageImplTest.kt +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/storage/favourite/FavouriteContentLocalStorageImplTest.kt @@ -1,4 +1,4 @@ -package org.fnives.test.showcase.favourite +package org.fnives.test.showcase.storage.favourite import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -42,6 +42,14 @@ internal class FavouriteContentLocalStorageImplTest : KoinTest { stopKoin() } + /** GIVEN just created database WHEN querying THEN empty list is returned */ + @Test + fun atTheStartOurDatabaseIsEmpty() = runTest(testDispatcher) { + val actual = sut.observeFavourites().first() + + Assert.assertEquals(emptyList(), actual) + } + /** GIVEN content_id WHEN added to Favourite THEN it can be read out */ @Test fun addingContentIdToFavouriteCanBeLaterReadOut() = runTest(testDispatcher) { @@ -69,7 +77,6 @@ internal class FavouriteContentLocalStorageImplTest : KoinTest { @Test fun addingFavouriteUpdatesExistingObservers() = runTest(testDispatcher) { val expected = listOf(listOf(), listOf(ContentId("a"))) - val actual = async(coroutineContext) { sut.observeFavourites().take(2).toList() } advanceUntilIdle() @@ -95,4 +102,19 @@ internal class FavouriteContentLocalStorageImplTest : KoinTest { Assert.assertEquals(expected, actual.getCompleted()) } + + /** GIVEN an observed WHEN adding and removing from it THEN we only get the expected amount of updates */ + @Test + fun noUnexpectedUpdates() = runTest(testDispatcher) { + val actual = async(coroutineContext) { sut.observeFavourites().take(4).toList() } + advanceUntilIdle() + + sut.markAsFavourite(ContentId("a")) + advanceUntilIdle() + sut.deleteAsFavourite(ContentId("a")) + advanceUntilIdle() + + Assert.assertFalse(actual.isCompleted) + actual.cancel() + } } diff --git a/codekata/robolectric.instructionset.md b/codekata/robolectric.instructionset.md new file mode 100644 index 0000000..3c3aab5 --- /dev/null +++ b/codekata/robolectric.instructionset.md @@ -0,0 +1,237 @@ +# 3. Starting of Robolectric testing + +So we are finally here, so far we didn't had to touch any kind of context or resources, activities, fragments or anything android. This is where we have to get back to reality and actually deal with Android. + +In this testing instruction set you will learn how to write simple tests using Robolectric. + +- We will learn why Robolectric is useful +- Learn how to test Room daos +- Learn how to test Room Migrations +- Learn what a Robolectric Shadow is +- And Learn how to write basic UI tests + +## FavouriteContentLocalStorage test + +Our System Under Test will be `org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage` or more precisely it's implementation: `org.fnives.test.showcase.storage.favourite.FavouriteContentLocalStorageImpl` + +What it does is: +- it's an abstraction over the Room DAO +- has 3 methods: observe, add and delete +- it gets the data from Room and updates Room + +### Setup + +So let's start with the setup. + +Our test class is `org.fnives.test.showcase.storage.favourite.CodeKataFavouriteContentLocalStorage` + +Question: Why don't we test the DAO and Storage separately using mocking? +Answer: The same logic applies how we didn't test the RetrofitServices just the RemoteSources. The Service just like the DAO is an implementation detail, our code only accesses them through the RemoteSource / LocalStorage abstraction. With this in mind now we only want to test that we interact with the database properly, we don't really care how many DAOs are used. + +We don't add anything Robolectric just yet, let's try to do this without it first. + +Let's setup or System Under Test as usual: + +```kotlin +private lateinit var sut: FavouriteContentLocalStorage // notice we only care about the interface + +@Before +fun setup() { + val room = Room.inMemoryDatabaseBuilder(mock(), LocalDatabase::class.java) // we are using inmemory, cause we don't really want to create files. + .allowMainThreadQueries() // we don't really care about threading for now + .build() + + sut = FavouriteContentLocalStorageImpl(room.favouriteDao) +} + +@Test +fun atTheStartOurDatabaseIsEmpty() = runBlocking { + // we just verify our setup is correct + sut.observeFavourites().first() +} +``` + +Let's run our test and see: + +> Method getWritableDatabase in android.database.sqlite.SQLiteOpenHelper not mocked. See http://g.co/androidstudio/not-mocked for details. +> java.lang.RuntimeException: Method getWritableDatabase in android.database.sqlite.SQLiteOpenHelper not mocked. See http://g.co/androidstudio/not-mocked for details. +> at android.database.sqlite.SQLiteOpenHelper.getWritableDatabase(SQLiteOpenHelper.java) + + +So we need to mock something inside the `SQLiteOpenHelper` which is used inside the Dao and Room in order to test the Database. +Well, I would rather not do that. So then we need to test on a Real Device or Emulator. Well we could, but then we need to integrate a Testing Farm with our CI. It would be good to do that, but sometimes that's just not possible, here is where [Robolectric](http://robolectric.org/) comes in. + +>Robolectric is the industry-standard unit testing framework for Android. With Robolectric, your tests run in a simulated Android environment inside a JVM, without the overhead and flakiness of an emulator. Robolectric tests routinely run 10x faster than those on cold-started emulators. + +### Setup with Robolectric + +We already have the dependencies in the project. +We need to annotate our class with `@RunWith(AndroidJUnit4::class)` +With this Robolectric actually starts our `TestShowcaseApplication` so instead of creating our SUT, we just inject it. However to easily inject with Koin, we extend `KoinTest`: +```kotlin +@RunWith(AndroidJUnit4::class) +class CodeKataFavouriteContentLocalStorage: KoinTest +``` + +So additional changes will be: +- remove our previous mocking attempt +- we inject our SUT +- we stop koin in tearDown +- we add a testDispatcher to Room +- we switch to runTest(testDispatcher) + +Since Room has their own exercutors, that could make our tests flaky, since we might get out of sync. Luckily we can switch out these executors, so we do that to make sure our tests run just as we would like them to. + +``` +private val sut by inject() +private lateinit var testDispatcher: TestDispatcher + +@Before +fun setUp() { + testDispatcher = StandardTestDispatcher(TestCoroutineScheduler()) + DatabaseInitialization.dispatcher = testDispatcher +} + +@After +fun tearDown() { + stopKoin() +} + +@Test +fun atTheStartOurDatabaseIsEmpty()= runTest(testDispatcher) { + sut.observeFavourites().first() +} +``` + +The line `DatabaseInitialization.dispatcher = testDispatcher` may look a bit mysterious, but all we do her is overwrite our iriginal DatabaseInitialization in tests, and use the given Dispatcher as an executor for Room setup. + +Now if we run our test we see we can indeed access the database. We can get down to actual testing. + +### 1. `atTheStartOurDatabaseIsEmpty` + +Since we used this test for our setup, we just need to finish it. We just verify the returned list is empty, so: +```kotlin +@Test +fun atTheStartOurDatabaseIsEmpty() = runTest(testDispatcher) { + val actual = sut.observeFavourites().first() + + Assert.assertEquals(emptyList(), actual) + // note we are using Assert instead of Assertions, that's because Robolectric and AndroidTest support JUnit4 and not JUnit5 we used previously. The @Test @Before etc annotations are also different. +} +``` + +### 2. `addingContentIdToFavouriteCanBeLaterReadOut` + +Time to test some actual logic. Let's see if we add an element to the Database, we indead can query it back. +First we declare what we expect: +```kotlin +val expected = listOf(ContentId("a")) +``` + +We do the action: +```kotlin +sut.markAsFavourite(ContentId("a")) +val actual = sut.observeFavourites().first() +``` + +And at the end verify: +```kotlin +Assert.assertEquals(expected, actual) +``` + +It is as simple as that. + +### 3. `contentIdAddedThenRemovedCanNoLongerBeReadOut` + +So we can add to the Database, let's see if we can remove from it. +We expect nothing, and we add an element as a setup: +```kotlin +val expected = listOf() +sut.markAsFavourite(ContentId("b")) +``` + +We do the action: +```kotlin +sut.deleteAsFavourite(ContentId("b")) +val actual = sut.observeFavourites().first() +``` + +And just verify our expectation: +```kotlin +Assert.assertEquals(expected, actual) +``` + +So we can delete as well. + +### 4. `addingFavouriteUpdatesExistingObservers` +Until now we just verified that afterwards we get the correct data, but what if we already subscribed? Do we still get the correct updates? + +So we setup our expectations and our observer: +```kotlin +val expected = listOf(listOf(), listOf(ContentId("observe"))) +val actual = async(coroutineContext) { sut.observeFavourites().take(2).toList() } +advanceUntilIdle() // we sync, so we get the first element that is in the database (which is the emptyList). +``` + +Now we do the action and synchronize again, so our observer is potentially updated: +```kotlin +sut.markAsFavourite(ContentId("a")) +advanceUntilIdle() +``` + +And let's assert that indeed we only get these two updates and no more things happening. To do this we won't wait for the async, but just get it's Completed value, aka ensure it is finished. + +```kotlin +Assert.assertEquals(expected, actual.getCompleted()) +``` + +##### Note: we can use turbine as well to verify our flows, just like we did previously + +### 5. `removingFavouriteUpdatesExistingObservers` + +Okay, this should be really similar to `addingFavouriteUpdatesExistingObservers` just with a hint of `contentIdAddedThenRemovedCanNoLongerBeReadOut` so try to write it on your own. + +However for completness sake: +```kotlin +val expected = listOf(listOf(ContentId("a")), listOf()) +sut.markAsFavourite(ContentId("a")) + +val actual = async(coroutineContext) { + sut.observeFavourites().take(2).toList() +} +advanceUntilIdle() + +sut.deleteAsFavourite(ContentId("a")) +advanceUntilIdle() + +Assert.assertEquals(expected, actual.getCompleted()) +``` + +### 6.`noUnexpectedUpdates` +Until now, just like with Flow tests in core, we assumed the number of updates. +So it's time to verify that we don't get unexpected updates on our flow. + +To do this we don't really care about the results, just that the number of updates are correct. So let's observe the database with the Correct Update Count + 1. +```kotlin +val actual = async(coroutineContext) { sut.observeFavourites().take(4).toList() } +advanceUntilIdle() // we expect to get our first result with emptyList() +``` + +We modify the database: +```kotlin +sut.markAsFavourite(ContentId("a")) +advanceUntilIdle() // we expect to get our second update with added ContentID +sut.deleteAsFavourite(ContentId("a")) +advanceUntilIdle() // we expect to get our third update with emptyList again +``` + +And now we verify that the observation did not complete, aka no 4th update was received: +```kotlin +Assert.assertFalse(actual.isCompleted) +actual.cancel() +``` + +With that we know how to verify our Database running on the JVM, without needing an emulator or device. + +## Conclusion + diff --git a/codekata/storage.instructionset b/codekata/storage.instructionset deleted file mode 100644 index 30404ce..0000000 --- a/codekata/storage.instructionset +++ /dev/null @@ -1 +0,0 @@ -TODO \ No newline at end of file diff --git a/core/src/test/java/org/fnives/test/showcase/core/login/CodeKataSecondLoginUseCaseTest.kt b/core/src/test/java/org/fnives/test/showcase/core/login/CodeKataSecondLoginUseCaseTest.kt index a689882..95bf4f5 100644 --- a/core/src/test/java/org/fnives/test/showcase/core/login/CodeKataSecondLoginUseCaseTest.kt +++ b/core/src/test/java/org/fnives/test/showcase/core/login/CodeKataSecondLoginUseCaseTest.kt @@ -10,36 +10,30 @@ class CodeKataSecondLoginUseCaseTest { @BeforeEach fun setUp() { - TODO() } @DisplayName("GIVEN empty username WHEN trying to login THEN invalid username is returned") @Test fun emptyUserNameReturnsLoginStatusError() { - TODO() } @DisplayName("GIVEN empty password WHEN trying to login THEN invalid password is returned") @Test fun emptyPasswordNameReturnsLoginStatusError() { - TODO() } @DisplayName("GIVEN invalid credentials response WHEN trying to login THEN invalid credentials is returned ") @Test fun invalidLoginResponseReturnInvalidCredentials() { - TODO() } @DisplayName("GIVEN success response WHEN trying to login THEN session is saved and success is returned") @Test fun validResponseResultsInSavingSessionAndSuccessReturned() { - TODO() } - @DisplayName("GIVEN error resposne WHEN trying to login THEN session is not touched and error is returned") + @DisplayName("GIVEN error response WHEN trying to login THEN session is not touched and error is returned") @Test fun invalidResponseResultsInErrorReturned() { - TODO() } }