Skip to content

Commit

Permalink
Add basic e2e tests
Browse files Browse the repository at this point in the history
Setup a basic structure for e2e tests.

- Add compose, hilt and networking dependencies
- Run instrumented tests using the orchestrator
- Setup the test runner
- Setup the test network dependencies
- Create abstraction to setup mock server responses
- Add 3 basic tests
  • Loading branch information
fibelatti committed Aug 5, 2023
1 parent 2a983b4 commit 3e68da4
Show file tree
Hide file tree
Showing 7 changed files with 252 additions and 8 deletions.
17 changes: 15 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ android {

resourceConfigurations.add("en")

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunner = "com.fibelatti.pinboard.HiltTestRunner"
testInstrumentationRunnerArguments["clearPackageData"] = "true"

vectorDrawables.useSupportLibrary = true

ksp {
Expand Down Expand Up @@ -134,6 +136,10 @@ android {
kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get()
}

testOptions {
execution = "ANDROIDX_TEST_ORCHESTRATOR"
}

packaging {
resources.excludes.add("META-INF/LICENSE.md")
resources.excludes.add("META-INF/LICENSE-notice.md")
Expand Down Expand Up @@ -211,10 +217,17 @@ dependencies {
testImplementation(libs.coroutines.test)
testImplementation(libs.arch.core.testing)

androidTestImplementation(libs.truth)
androidTestImplementation(libs.runner)
androidTestUtil(libs.orchestrator)

androidTestImplementation(libs.truth)
androidTestImplementation(libs.coroutines.test)
androidTestImplementation(libs.room.testing)
androidTestImplementation(libs.compose.ui.test.junit4)
debugImplementation(libs.compose.ui.test.manifest)
androidTestImplementation(libs.hilt.android.testing)
kaptAndroidTest(libs.hilt.android.compiler)
androidTestImplementation(libs.mockwebserver)
}

kapt {
Expand Down
80 changes: 80 additions & 0 deletions app/src/androidTest/kotlin/com/fibelatti/pinboard/EndToEndTests.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package com.fibelatti.pinboard

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.platform.app.InstrumentationRegistry
import com.fibelatti.pinboard.core.util.DateFormatter
import com.fibelatti.pinboard.features.MainActivity
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject

@HiltAndroidTest
class EndToEndTests {

@get:Rule
val hiltRule = HiltAndroidRule(this)

@get:Rule
val composeRule = createAndroidComposeRule<MainActivity>()

@Inject
lateinit var dateFormatter: DateFormatter

private val context by lazy {
InstrumentationRegistry.getInstrumentation().targetContext
}

@Before
fun setup() {
hiltRule.inject()
}

@Test
fun loginScreenIsVisibleWhenFirstLaunchingTheApp() {
with(composeRule) {
// Assert
onNodeWithText(context.getString(R.string.auth_title)).assertIsDisplayed()
}
}

@Test
fun userCanLaunchAppReviewMode() {
with(composeRule) {
// Act
onNodeWithText(context.getString(R.string.auth_token_hint)).performTextInput("app_review_mode")
onNodeWithText(context.getString(R.string.auth_button)).performClick()

// Assert
onNodeWithText(context.getString(R.string.posts_title_all)).assertIsDisplayed()
onNodeWithText(context.getString(R.string.posts_empty_title)).assertIsDisplayed()
}
}

@Test
fun userCanLoginAndFetchBookmarks() {
// Arrange
MockServer.loginResponses(updateTimestamp = dateFormatter.nowAsTzFormat())

with(composeRule) {
// Act
onNodeWithText(context.getString(R.string.auth_token_hint)).performTextInput(MockServer.TestData.TOKEN)
onNodeWithText(context.getString(R.string.auth_button)).performClick()

// Assert
onNodeWithText(context.getString(R.string.posts_title_all)).assertIsDisplayed()
onNodeWithText("Google").assertIsDisplayed()
onNodeWithText("Private").assertIsDisplayed()
onNodeWithText("Read later").assertIsDisplayed()
onNodeWithText("Instrumented test").assertIsDisplayed()
onNodeWithText("android").assertIsDisplayed()
onNodeWithText("dev").assertIsDisplayed()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.fibelatti.pinboard

import android.app.Application
import android.content.Context
import android.os.StrictMode
import androidx.test.runner.AndroidJUnitRunner
import dagger.hilt.android.testing.HiltTestApplication

@Suppress("Unused")
class HiltTestRunner : AndroidJUnitRunner() {

override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
// Workaround to setup the MockWebServer
StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder().permitAll().build())
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
}
71 changes: 71 additions & 0 deletions app/src/androidTest/kotlin/com/fibelatti/pinboard/MockServer.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.fibelatti.pinboard

import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest

object MockServer {

val instance = MockWebServer()

fun loginResponses(updateTimestamp: String) {
setResponses(
"/posts/update" to {
MockResponse().setResponseCode(200)
.setBody(TestData.updateResponse(timestamp = updateTimestamp))
},
"/posts/all" to { request ->
MockResponse().setResponseCode(200).apply {
if (request.requestUrl.toString().contains("start=0")) {
setBody(TestData.allBookmarksResponse())
} else {
setBody(TestData.emptyBookmarksResponse())
}
}
},
)
}

private fun setResponses(vararg responses: Pair<String, (RecordedRequest) -> MockResponse>) {
instance.dispatcher = object : Dispatcher() {
private val handlers = responses.toList()

override fun dispatch(request: RecordedRequest): MockResponse {
val requestPath = request.path.orEmpty()
val handler = handlers.firstOrNull { (path, _) -> requestPath.contains(path) }?.second

return handler?.invoke(request) ?: MockResponse().setResponseCode(404)
}
}
}

object TestData {

const val TOKEN = "instrumented:1000"

fun updateResponse(timestamp: String): String = """
{
"update_time":"$timestamp"
}
""".trimIndent()

fun allBookmarksResponse(): String = """
[
{
"href":"https:\/\/www.google.com",
"description":"Google",
"extended":"Instrumented test",
"meta":"d0ec3d2af45baa4365121cacab6166d3",
"hash":"8ffdefbdec956b595d257f0aaeefd623",
"time":"2023-08-05T11:51:33Z",
"shared":"no",
"toread":"yes",
"tags":"android dev"
}
]
""".trimIndent()

fun emptyBookmarksResponse(): String = "[]"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.fibelatti.pinboard.core.di.modules

import com.fibelatti.pinboard.MockServer
import com.fibelatti.pinboard.core.di.UrlParser
import com.fibelatti.pinboard.core.network.ApiInterceptor
import com.fibelatti.pinboard.core.network.SkipBadElementsListAdapter
import com.fibelatti.pinboard.core.network.UnauthorizedInterceptor
import com.squareup.moshi.Moshi
import dagger.Module
import dagger.Provides
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import javax.inject.Singleton

@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [NetworkModule::class],
)
object TestNetworkModule {

@Provides
@Singleton
fun retrofit(okHttpClient: OkHttpClient, moshi: Moshi): Retrofit = Retrofit.Builder()
.baseUrl(MockServer.instance.url("/"))
.client(okHttpClient)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()

@Provides
fun moshi(): Moshi = Moshi.Builder().add(SkipBadElementsListAdapter.Factory).build()

@Provides
fun okHttpClient(
apiInterceptor: ApiInterceptor,
unauthorizedInterceptor: UnauthorizedInterceptor,
loggingInterceptor: HttpLoggingInterceptor,
): OkHttpClient = OkHttpClient.Builder()
.addInterceptor(apiInterceptor)
.addInterceptor(unauthorizedInterceptor)
.addInterceptor(loggingInterceptor)
.build()

@Provides
@UrlParser
fun urlParserOkHttpClient(
loggingInterceptor: HttpLoggingInterceptor,
): OkHttpClient = OkHttpClient.Builder()
.followRedirects(true)
.followSslRedirects(true)
.addInterceptor(loggingInterceptor)
.build()

@Provides
fun httpLoggingInterceptor(): HttpLoggingInterceptor = HttpLoggingInterceptor()
.apply { level = HttpLoggingInterceptor.Level.BODY }
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import com.fibelatti.pinboard.BuildConfig
import com.fibelatti.pinboard.core.AppConfig
import com.fibelatti.pinboard.core.di.UrlParser
import com.fibelatti.pinboard.core.network.ApiInterceptor
import com.fibelatti.pinboard.core.network.ApiRateLimitRunner
import com.fibelatti.pinboard.core.network.RateLimitRunner
import com.fibelatti.pinboard.core.network.SkipBadElementsListAdapter
import com.fibelatti.pinboard.core.network.UnauthorizedInterceptor
import com.squareup.moshi.Moshi
Expand Down Expand Up @@ -81,8 +79,4 @@ object NetworkModule {
@Provides
fun httpLoggingInterceptor(): HttpLoggingInterceptor = HttpLoggingInterceptor()
.apply { level = HttpLoggingInterceptor.Level.BODY }

@Provides
@Singleton
fun rateLimitRunner(): RateLimitRunner = ApiRateLimitRunner(AppConfig.API_THROTTLE_TIME)
}
8 changes: 8 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,20 @@ junit5-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref
junit5-vintage = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit5" }

runner = { module = "androidx.test:runner", version = "1.5.2" }
orchestrator = { module = "androidx.test:orchestrator", version = "1.4.2" }

truth = { module = "com.google.truth:truth", version = "1.1.5" }
mockk = { module = "io.mockk:mockk", version = "1.13.5" }
coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
arch-core-testing = { module = "androidx.arch.core:core-testing", version = "2.2.0" }
room-testing = { module = "androidx.room:room-testing", version.ref = "room" }

compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version = "1.4.3" }
compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version = "1.4.3" }
hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" }
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version = "4.11.0" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
Expand Down

0 comments on commit 3e68da4

Please sign in to comment.