From 9cfe670ae08cd2c842f3bd9e2fc1d760041ca06a Mon Sep 17 00:00:00 2001 From: David Blanc Date: Wed, 17 Jul 2019 20:39:33 +0200 Subject: [PATCH 01/32] Update issue templates --- .github/ISSUE_TEMPLATE/bug_report.md | 38 +++++++++++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 20 ++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..dd84ea78 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..bbcbbe7d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. From b41aacf29e860f3203a63b87fd30cd7b114e0ca1 Mon Sep 17 00:00:00 2001 From: David Blanc Date: Wed, 17 Jul 2019 20:39:33 +0200 Subject: [PATCH 02/32] Bump version --- README.md | 14 +++++++------- gradle/versions.gradle | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 7f4ca532..243f813a 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ configuration files instead. The interceptor will also allow to record scenarios ## Current Version ```gradle -httpmocker_version = '1.1.5' +httpmocker_version = '1.1.6' ``` ## Gradle @@ -55,19 +55,19 @@ you need to add is the corresponding ```gradle // Parses JSON scenarios using Jackson -implementation "fr.speekha.httpmocker:jackson-adapter:1.1.5" +implementation "fr.speekha.httpmocker:jackson-adapter:1.1.6" // Parses JSON scenarios using Gson -implementation "fr.speekha.httpmocker:gson-adapter:1.1.5" +implementation "fr.speekha.httpmocker:gson-adapter:1.1.6" // Parses JSON scenarios using Moshi -implementation "fr.speekha.httpmocker:moshi-adapter:1.1.5" +implementation "fr.speekha.httpmocker:moshi-adapter:1.1.6" // Parses JSON scenarios using Kotlinx Serialization -implementation "fr.speekha.httpmocker:kotlinx-adapter:1.1.5" +implementation "fr.speekha.httpmocker:kotlinx-adapter:1.1.6" // Parses JSON scenarios using a custom JSON parser -implementation "fr.speekha.httpmocker:custom-adapter:1.1.5" +implementation "fr.speekha.httpmocker:custom-adapter:1.1.6" ``` If none of those options suits your needs or if you would prefer to only use dynamic mocks, you can add @@ -75,7 +75,7 @@ the main dependency to your project (using static mocks will require that you pr of the `Mapper` class): ```gradle -implementation "fr.speekha.httpmocker:mocker:1.1.5" +implementation "fr.speekha.httpmocker:mocker:1.1.6" ``` #### External dependencies diff --git a/gradle/versions.gradle b/gradle/versions.gradle index 4365089e..1645ebd5 100644 --- a/gradle/versions.gradle +++ b/gradle/versions.gradle @@ -15,7 +15,7 @@ */ ext { - httpmock_version = '1.1.5' + httpmock_version = '1.1.6' kotlin_version = '1.3.41' coroutines_version = '1.2.1' From 449576cd1b9c4bc7f62838583e4a3c683c95c162 Mon Sep 17 00:00:00 2001 From: David Blanc Date: Wed, 17 Jul 2019 22:03:48 +0200 Subject: [PATCH 03/32] Update circle-ci image --- .circleci/config.yml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index f2b16f88..c528d599 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -30,7 +30,7 @@ commands: jobs: prepare_dependencies: docker: - - image: circleci/android:api-28 + - image: circleci/android:api-29 environment: JVM_OPTS: -Xmx3200m #TERM: dumb @@ -66,7 +66,7 @@ jobs: - "." build_core: docker: - - image: circleci/android:api-28 + - image: circleci/android:api-29 environment: JVM_OPTS: -Xmx3200m steps: @@ -74,7 +74,7 @@ jobs: module: "mocker" build_jackson: docker: - - image: circleci/android:api-28 + - image: circleci/android:api-29 environment: JVM_OPTS: -Xmx3200m steps: @@ -82,7 +82,7 @@ jobs: module: "jackson-adapter" build_gson: docker: - - image: circleci/android:api-28 + - image: circleci/android:api-29 environment: JVM_OPTS: -Xmx3200m steps: @@ -90,7 +90,7 @@ jobs: module: "gson-adapter" build_moshi: docker: - - image: circleci/android:api-28 + - image: circleci/android:api-29 environment: JVM_OPTS: -Xmx3200m steps: @@ -98,7 +98,7 @@ jobs: module: "moshi-adapter" build_custom: docker: - - image: circleci/android:api-28 + - image: circleci/android:api-29 environment: JVM_OPTS: -Xmx3200m steps: @@ -106,7 +106,7 @@ jobs: module: "custom-adapter" build_kotlinx: docker: - - image: circleci/android:api-28 + - image: circleci/android:api-29 environment: JVM_OPTS: -Xmx3200m steps: @@ -114,7 +114,7 @@ jobs: module: "kotlinx-adapter" build_demo: docker: - - image: circleci/android:api-28 + - image: circleci/android:api-29 environment: JVM_OPTS: -Xmx3200m steps: @@ -143,7 +143,7 @@ jobs: - demo/build store_artifacts: docker: - - image: circleci/android:api-28 + - image: circleci/android:api-29 environment: JVM_OPTS: -Xmx3200m steps: @@ -187,7 +187,7 @@ jobs: destination: apks test: docker: - - image: circleci/android:api-28 + - image: circleci/android:api-29 environment: JVM_OPTS: -Xmx3200m steps: @@ -231,7 +231,7 @@ jobs: - "." publish_snapshot: docker: - - image: circleci/android:api-28 + - image: circleci/android:api-29 steps: - restore_cache: key: build-{{ .Branch }}-{{ .Revision }} @@ -242,7 +242,7 @@ jobs: command: ./publishSnapshot.sh ${BINTRAY_USER} ${BINTRAY_APIKEY} publish_release: docker: - - image: circleci/android:api-28 + - image: circleci/android:api-29 steps: - restore_cache: key: build-{{ .Branch }}-{{ .Revision }} From 77ce442ede7c273855b95df31d5f9af7af38da4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ste=CC=81phane=20Baiget?= Date: Fri, 19 Jul 2019 09:43:21 +0200 Subject: [PATCH 04/32] Correct warnings on style. --- demo/src/main/res/layout/activity_main.xml | 169 +++++++++++---------- 1 file changed, 90 insertions(+), 79 deletions(-) diff --git a/demo/src/main/res/layout/activity_main.xml b/demo/src/main/res/layout/activity_main.xml index dca782b0..47f40764 100644 --- a/demo/src/main/res/layout/activity_main.xml +++ b/demo/src/main/res/layout/activity_main.xml @@ -14,96 +14,107 @@ ~ limitations under the License. --> - + + android:id="@+id/tvState" + style="?textAppearanceSubtitle1" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="16dp" + android:layout_marginTop="16dp" + android:text="@string/mocking_state" + app:layout_constraintLeft_toLeftOf="parent" + app:layout_constraintTop_toTopOf="parent" + /> - + - + - + - + - + android:id="@+id/stateDisabled" + style="@style/Widget.MaterialComponents.Button.OutlinedButton" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/state_disabled" + /> - + android:text="@string/state_enabled" + /> - + android:text="@string/state_mixed" + /> - + + \ No newline at end of file From 890d591774cd1fb8c270938e8409df0d8e5fc006 Mon Sep 17 00:00:00 2001 From: David Blanc Date: Sat, 20 Jul 2019 21:52:35 +0200 Subject: [PATCH 05/32] Handle quotes in strings --- .../httpmocker/custom/JsonSerialization.kt | 8 +++--- .../httpmocker/custom/JsonStringReader.kt | 5 ++-- .../fr/speekha/httpmocker/gson/GsonMapper.kt | 1 + .../mappers/AbstractJsonMapperTest.kt | 26 ++++++++++++++++++- .../mappers/JsonStringReaderTest.kt | 10 ++++++- .../fr/speekha/httpmocker/mappers/TestData.kt | 3 ++- tests/src/test/resources/complete_input.json | 3 ++- 7 files changed, 46 insertions(+), 10 deletions(-) diff --git a/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonSerialization.kt b/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonSerialization.kt index 93fcf660..1f160db3 100644 --- a/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonSerialization.kt +++ b/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonSerialization.kt @@ -55,14 +55,14 @@ internal fun RequestDescriptor.toJson(): String = listOf( postfix = "\n }" ) { (key, value) -> " \"$key\": $value" } -internal fun Map.toJson(): String = +internal fun Map.toJson(): String = entries.joinToString( separator = ",\n", prefix = "{\n", postfix = "\n }" - ) { " \"${it.key}\": \"${it.value}\"" } + ) { " \"${it.key}\": ${it.value.wrap()}" } -internal fun Header.toJson(): String = "\"$name\": \"$value\"" +internal fun Header.toJson(): String = "\"$name\": ${value.wrap()}" internal fun ResponseDescriptor.toJson(): String = listOf( "delay" to delay.toString(), @@ -79,7 +79,7 @@ internal fun ResponseDescriptor.toJson(): String = listOf( postfix = "\n }" ) { (key, value) -> " \"$key\": $value" } -private fun String?.wrap() = this?.let { "\"$it\"" } +private fun String?.wrap() = this?.let { "\"${it.replace("\"", "\\\"")}\"" } private fun Int?.wrap() = this?.toString() diff --git a/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonStringReader.kt b/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonStringReader.kt index 331a491e..e3298c2a 100644 --- a/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonStringReader.kt +++ b/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonStringReader.kt @@ -151,12 +151,13 @@ class JsonStringReader( private fun extractStringLiteral(): String { val start = json.indexOf("\"", index) - val end = json.indexOf("\"", start + 1) + val match = Regex("[^\\\\]\"").find(json, start) + val end = match?.range?.endInclusive ?: -1 if (start < 1 || end == -1 || !isBlank(index, start)) { parseError(WRONG_START_OF_STRING_ERROR) } index = end + 1 - return json.substring(start + 1, end) + return json.substring(start + 1, end).replace("\\\"", "\"") } private fun extractNumericLiteral(): String { diff --git a/gson-adapter/src/main/kotlin/fr/speekha/httpmocker/gson/GsonMapper.kt b/gson-adapter/src/main/kotlin/fr/speekha/httpmocker/gson/GsonMapper.kt index df0bade6..f3fd4000 100644 --- a/gson-adapter/src/main/kotlin/fr/speekha/httpmocker/gson/GsonMapper.kt +++ b/gson-adapter/src/main/kotlin/fr/speekha/httpmocker/gson/GsonMapper.kt @@ -37,6 +37,7 @@ class GsonMapper : Mapper { private val adapter: Gson = GsonBuilder() .setPrettyPrinting() + .disableHtmlEscaping() .registerTypeAdapter(HeaderAdapter.HeaderList::class.java, HeaderAdapter()) .create() diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/AbstractJsonMapperTest.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/AbstractJsonMapperTest.kt index bee0803c..0243350e 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/AbstractJsonMapperTest.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/AbstractJsonMapperTest.kt @@ -37,7 +37,6 @@ abstract class AbstractJsonMapperTest(val mapper: Mapper) { assertEquals(partialData, result) } - @Test fun `should handle headers with colons`() { val json = """[ @@ -63,6 +62,31 @@ abstract class AbstractJsonMapperTest(val mapper: Mapper) { ) } + @Test + fun `should handle headers with quotes`() { + val json = """[ + { + "response": { + "headers": { + "Set-Cookie": "\"cookie\"=\"value\"" + } + } + } +]""" + + assertEquals( + listOf( + Matcher( + response = ResponseDescriptor( + headers = listOf( + Header("Set-Cookie", "\"cookie\"=\"value\"") + ) + ) + ) + ), mapper.readMatches(json.byteInputStream()) + ) + } + @Test fun `should write a proper JSON file`() { val expected = getExpectedOutput() diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JsonStringReaderTest.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JsonStringReaderTest.kt index d31794c5..68e069a3 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JsonStringReaderTest.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JsonStringReaderTest.kt @@ -35,7 +35,6 @@ import java.util.stream.Stream class JsonStringReaderTest { - @Test fun `should parse empty string`() { val reader = JsonStringReader("") @@ -107,6 +106,15 @@ class JsonStringReaderTest { assertEquals(result, reader.readString()) } + @Test + fun `should read string with quotes`() { + val result = """a test \"string\"""" + val reader = JsonStringReader("{\"field\":\"$result\"}") + reader.beginObject() + reader.readFieldName() + assertEquals("""a test "string"""", reader.readString()) + } + @ParameterizedTest @MethodSource("stringErrors") fun `should detect error on read string`(input: String, output: String) { diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/TestData.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/TestData.kt index ebec1fca..ed09b2dd 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/TestData.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/TestData.kt @@ -35,7 +35,8 @@ internal val completeData = listOf( headers = listOf( Header("reqHeader1", "1"), Header("reqHeader1", "2"), - Header("reqHeader2", "3") + Header("reqHeader2", "3"), + Header("Set-Cookie", "\"cookie\"=\"value\"") ), params = mapOf("param1" to "1", "param2" to "2"), body = ".*1.*" diff --git a/tests/src/test/resources/complete_input.json b/tests/src/test/resources/complete_input.json index 421286eb..441d4497 100644 --- a/tests/src/test/resources/complete_input.json +++ b/tests/src/test/resources/complete_input.json @@ -9,7 +9,8 @@ "headers": { "reqHeader1": "1", "reqHeader1": "2", - "reqHeader2": "3" + "reqHeader2": "3", + "Set-Cookie": "\"cookie\"=\"value\"" }, "params": { "param1": "1", From b433727dc7edfa3be11d0992bc8170084acd237d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ste=CC=81phane=20Baiget?= Date: Fri, 19 Jul 2019 08:09:54 +0200 Subject: [PATCH 06/32] Move to Android JetPack ViewModel. --- demo/build.gradle | 20 ++- .../httpmocker/demo/di/InjectionModule.kt | 10 +- .../httpmocker/demo/ui/MainActivity.kt | 52 +++++-- .../httpmocker/demo/ui/MainContract.kt | 37 ----- .../httpmocker/demo/ui/MainPresenter.kt | 84 ----------- .../speekha/httpmocker/demo/ui/MainScope.kt | 30 ---- .../httpmocker/demo/ui/MainViewModel.kt | 86 +++++++++++ demo/src/main/res/layout/activity_main.xml | 27 +++- .../httpmocker/demo/ui/CoroutinesTestRule.kt | 26 ++++ .../httpmocker/demo/ui/MainViewModelTest.kt | 134 ++++++++++++++++++ .../org.mockito.plugins.MockMaker | 1 + gradle/versions.gradle | 2 + tests/build.gradle | 4 +- 13 files changed, 329 insertions(+), 184 deletions(-) delete mode 100644 demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainContract.kt delete mode 100644 demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainPresenter.kt delete mode 100644 demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainScope.kt create mode 100644 demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainViewModel.kt create mode 100644 demo/src/test/java/fr/speekha/httpmocker/demo/ui/CoroutinesTestRule.kt create mode 100644 demo/src/test/java/fr/speekha/httpmocker/demo/ui/MainViewModelTest.kt create mode 100644 demo/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker diff --git a/demo/build.gradle b/demo/build.gradle index 3dfa103d..38dbf1f0 100644 --- a/demo/build.gradle +++ b/demo/build.gradle @@ -15,9 +15,7 @@ */ apply plugin: 'com.android.application' - apply plugin: 'kotlin-android' - apply plugin: 'kotlin-android-extensions' @@ -47,7 +45,10 @@ android { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } - buildToolsVersion '28.0.3' + testOptions { + unitTests.returnDefaultValues = true + } + buildToolsVersion '29.0.1' } //repositories { @@ -56,12 +57,13 @@ android { //} dependencies { - implementation fileTree(include: ['*.jar'], dir: 'libs') - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" implementation "androidx.appcompat:appcompat:$support_version" + implementation "androidx.core:core-ktx:1.0.2" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.1.0-rc01" implementation 'androidx.constraintlayout:constraintlayout:1.1.3' implementation 'androidx.recyclerview:recyclerview:1.0.0' implementation 'com.google.android.material:material:1.1.0-alpha08' @@ -69,9 +71,15 @@ dependencies { implementation "com.squareup.retrofit2:retrofit:$retrofit_version" implementation "com.squareup.retrofit2:converter-jackson:$retrofit_version" - implementation "org.koin:koin-android:2.0.1" + implementation "org.koin:koin-androidx-viewmodel:2.0.1" implementation "org.slf4j:slf4j-android:$slf4j_version" //implementation "fr.speekha.httpmocker:jackson-adapter:$httpmock_version" implementation project(':jackson-adapter') + + testImplementation "junit:junit:4.12" + testImplementation "org.mockito:mockito-core:$mockito_version" + testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:$mockito_kotlin_version" + testImplementation "androidx.arch.core:core-testing:2.0.1" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" } diff --git a/demo/src/main/java/fr/speekha/httpmocker/demo/di/InjectionModule.kt b/demo/src/main/java/fr/speekha/httpmocker/demo/di/InjectionModule.kt index fdf977b8..d22116dd 100644 --- a/demo/src/main/java/fr/speekha/httpmocker/demo/di/InjectionModule.kt +++ b/demo/src/main/java/fr/speekha/httpmocker/demo/di/InjectionModule.kt @@ -20,11 +20,11 @@ import android.content.Context import android.os.Environment import fr.speekha.httpmocker.MockResponseInterceptor import fr.speekha.httpmocker.demo.service.GithubApiEndpoints -import fr.speekha.httpmocker.demo.ui.MainContract -import fr.speekha.httpmocker.demo.ui.MainPresenter +import fr.speekha.httpmocker.demo.ui.MainViewModel import fr.speekha.httpmocker.jackson.JacksonMapper import fr.speekha.httpmocker.policies.MirrorPathPolicy import okhttp3.OkHttpClient +import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.core.module.Module import org.koin.dsl.module import retrofit2.Retrofit @@ -62,5 +62,7 @@ val injectionModule: Module = module { get().create(GithubApiEndpoints::class.java) } - factory { (view: MainContract.View) -> MainPresenter(view, get(), get()) } -} \ No newline at end of file + viewModel { + MainViewModel(get(), get()) + } +} diff --git a/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainActivity.kt b/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainActivity.kt index 18bcd9cd..647f491c 100644 --- a/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainActivity.kt +++ b/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainActivity.kt @@ -20,30 +20,48 @@ import android.Manifest.permission.WRITE_EXTERNAL_STORAGE import android.content.pm.PackageManager import android.os.Build import android.os.Bundle -import androidx.annotation.IntegerRes +import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat +import androidx.core.view.isVisible +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer import androidx.recyclerview.widget.LinearLayoutManager import fr.speekha.httpmocker.MockResponseInterceptor import fr.speekha.httpmocker.demo.R import fr.speekha.httpmocker.demo.model.Repo import kotlinx.android.synthetic.main.activity_main.* -import org.koin.android.ext.android.inject -import org.koin.core.parameter.parametersOf +import org.koin.androidx.viewmodel.ext.android.viewModel -class MainActivity : AppCompatActivity(), MainContract.View { - private val presenter: MainContract.Presenter by inject { parametersOf(this) } +class MainActivity : AppCompatActivity() { + private val viewModel by viewModel() private val adapter = RepoAdapter(this) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) + observe(viewModel.getData()) { data -> + when (data) { + is Data.Loading -> showLoading(true) + is Data.Success -> setResult(data.repos) + is Data.Error -> setError(data.message) + } + } + + observe(viewModel.getState()) { state -> + when (state) { + is State.Permission -> checkPermission() + is State.Message -> updateDescriptionLabel(state.message) + } + } + radioState.addOnButtonCheckedListener { _, checkedId, isChecked -> if (isChecked) { - presenter.setMode( + viewModel.setMode( when (checkedId) { R.id.stateEnabled -> MockResponseInterceptor.Mode.ENABLED R.id.stateMixed -> MockResponseInterceptor.Mode.MIXED @@ -57,36 +75,42 @@ class MainActivity : AppCompatActivity(), MainContract.View { } btnCall.setOnClickListener { - presenter.callService() + viewModel.callService() } results.adapter = adapter results.layoutManager = LinearLayoutManager(this) } - override fun onStop() { - super.onStop() - presenter.stop() + private fun showLoading(visible: Boolean) { + results.isVisible = !visible + loader.isVisible = visible } - override fun setResult(result: List) { + private fun setResult(result: List) { + showLoading(false) adapter.repos = result adapter.notifyDataSetChanged() } - override fun setError(message: String?) { + private fun setError(message: String?) { + showLoading(false) adapter.repos = null adapter.errorMessage = message adapter.notifyDataSetChanged() } - override fun checkPermission() { + private fun checkPermission() { if (Build.VERSION.SDK_INT >= 23 && checkSelfPermission(WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, arrayOf(WRITE_EXTERNAL_STORAGE), 1) } } - override fun updateDescriptionLabel(@IntegerRes resId: Int) { + private fun updateDescriptionLabel(@StringRes resId: Int) { tvMessage.setText(resId) } } + +fun > LifecycleOwner.observe(liveData: L, body: (T?) -> Unit) { + liveData.observe(this, Observer(body)) +} diff --git a/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainContract.kt b/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainContract.kt deleted file mode 100644 index 999e7f72..00000000 --- a/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainContract.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright 2019 David Blanc - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.speekha.httpmocker.demo.ui - -import androidx.annotation.IntegerRes -import fr.speekha.httpmocker.MockResponseInterceptor -import fr.speekha.httpmocker.demo.model.Repo - -interface MainContract { - - interface View { - fun setResult(result: List) - fun setError(message: String?) - fun checkPermission() - fun updateDescriptionLabel(@IntegerRes resId: Int) - } - - interface Presenter { - fun stop() - fun callService() - fun setMode(mode: MockResponseInterceptor.Mode) - } -} \ No newline at end of file diff --git a/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainPresenter.kt b/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainPresenter.kt deleted file mode 100644 index 63391477..00000000 --- a/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainPresenter.kt +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright 2019 David Blanc - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.speekha.httpmocker.demo.ui - -import android.util.Log -import fr.speekha.httpmocker.MockResponseInterceptor -import fr.speekha.httpmocker.demo.R -import fr.speekha.httpmocker.demo.service.GithubApiEndpoints -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - - -class MainPresenter( - private val view: MainContract.View, - private val apiService: GithubApiEndpoints, - private val mocker: MockResponseInterceptor -) : MainContract.Presenter, CoroutineScope by MainScope() { - - override fun callService() { - launch { - try { - val org = "kotlin" - val repos = loadRepos(org) - .map { - val contributor = loadTopContributor(org, it.name)?.firstOrNull() - it.copy(topContributor = contributor?.run { "$login - $contributions contributions" }) - } - view.setResult(repos) - } catch (e: Throwable) { - view.setError(e.message) - } - } - } - - private suspend fun loadRepos(org: String) = withContext(Dispatchers.IO) { - apiService.listRepositoriesForOrganisation(org) - } - - private suspend fun loadTopContributor(org: String, repo: String) = - withContext(Dispatchers.IO) { - try { - apiService.listContributorsForRepository(org, repo) - } catch (e: Throwable) { - Log.e("Presenter", e.message, e) - null - } - } - - override fun setMode(mode: MockResponseInterceptor.Mode) { - mocker.mode = mode - if (mocker.mode == MockResponseInterceptor.Mode.RECORD) { - view.checkPermission() - } - view.updateDescriptionLabel( - when (mocker.mode) { - MockResponseInterceptor.Mode.DISABLED -> R.string.disabled_description - MockResponseInterceptor.Mode.ENABLED -> R.string.enabled_description - MockResponseInterceptor.Mode.MIXED -> R.string.mixed_description - MockResponseInterceptor.Mode.RECORD -> R.string.record_description - } - ) - } - - override fun stop() { - coroutineContext.cancel() - } -} \ No newline at end of file diff --git a/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainScope.kt b/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainScope.kt deleted file mode 100644 index f3537577..00000000 --- a/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainScope.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright 2019 David Blanc - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.speekha.httpmocker.demo.ui - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlin.coroutines.CoroutineContext - -class MainScope : CoroutineScope { - - val job = Job() - - override val coroutineContext: CoroutineContext = Dispatchers.Main + job - -} diff --git a/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainViewModel.kt b/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainViewModel.kt new file mode 100644 index 00000000..db54c533 --- /dev/null +++ b/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainViewModel.kt @@ -0,0 +1,86 @@ +package fr.speekha.httpmocker.demo.ui + +import android.util.Log +import androidx.annotation.StringRes +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import fr.speekha.httpmocker.MockResponseInterceptor +import fr.speekha.httpmocker.demo.R +import fr.speekha.httpmocker.demo.model.Repo +import fr.speekha.httpmocker.demo.service.GithubApiEndpoints +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class MainViewModel( + private val apiService: GithubApiEndpoints, + private val mocker: MockResponseInterceptor +) : ViewModel() { + + private val data = MutableLiveData() + private val state = MutableLiveData() + private val exceptionHandler = CoroutineExceptionHandler { _, exception -> + data.postValue(Data.Error(exception.message)) + } + + fun getData(): LiveData = data + fun getState(): LiveData = state + + fun callService() { + viewModelScope.launch(exceptionHandler) { + data.postValue(Data.Loading) + val org = "kotlin" + val repos = loadRepos(org) + .map { + val contributor = loadTopContributor(org, it.name)?.firstOrNull() + it.copy(topContributor = contributor?.run { "$login - $contributions contributions" }) + } + data.postValue(Data.Success(repos)) + } + } + + fun setMode(mode: MockResponseInterceptor.Mode) { + mocker.mode = mode + if (mocker.mode == MockResponseInterceptor.Mode.RECORD) { + state.postValue(State.Permission) + } + state.postValue( + State.Message( + when (mocker.mode) { + MockResponseInterceptor.Mode.DISABLED -> R.string.disabled_description + MockResponseInterceptor.Mode.ENABLED -> R.string.enabled_description + MockResponseInterceptor.Mode.MIXED -> R.string.mixed_description + MockResponseInterceptor.Mode.RECORD -> R.string.record_description + } + ) + ) + } + + private suspend fun loadRepos(org: String) = withContext(Dispatchers.IO) { + apiService.listRepositoriesForOrganisation(org) + } + + private suspend fun loadTopContributor(org: String, repo: String) = + withContext(Dispatchers.IO) { + try { + apiService.listContributorsForRepository(org, repo) + } catch (e: Throwable) { + Log.e("ViewModel", e.message, e) + null + } + } +} + +sealed class Data { + object Loading : Data() + data class Success(val repos: List) : Data() + data class Error(val message: String?) : Data() +} + +sealed class State { + data class Message(@StringRes val message: Int) : State() + object Permission : State() +} diff --git a/demo/src/main/res/layout/activity_main.xml b/demo/src/main/res/layout/activity_main.xml index 47f40764..149a115d 100644 --- a/demo/src/main/res/layout/activity_main.xml +++ b/demo/src/main/res/layout/activity_main.xml @@ -23,16 +23,17 @@ tools:context=".ui.MainActivity" > - + + () + private val mockResponseInterceptor = + MockResponseInterceptor.Builder().parseScenariosWith(mock()).build() + private lateinit var viewModel: MainViewModel + + @Before + fun setup() { + viewModel = MainViewModel(mockService, mockResponseInterceptor) + } + + @Test + fun `should load repos and top contributors successfully`() { + val observer = mock>() + viewModel.getData().observeForever(observer) + + coroutinesTestRule.testDispatcher.runBlockingTest { + whenever(mockService.listRepositoriesForOrganisation(org)) + .thenReturn(listOf(Repo(id, repo, topContributor = contributor))) + whenever(mockService.listContributorsForRepository(org, repo)) + .thenReturn(listOf(User(login = contributor, contributions = contributions))) + viewModel.callService() + } + + verify(observer).onChanged(Data.Loading) + verify(observer).onChanged( + Data.Success( + listOf( + Repo( + id, + repo, + topContributor = "$contributor - $contributions contributions" + ) + ) + ) + ) + viewModel.getData().removeObserver(observer) + } + + @Test + fun `should load repos successfully and fail top contributors`() { + val observer = mock>() + viewModel.getData().observeForever(observer) + + coroutinesTestRule.testDispatcher.runBlockingTest { + whenever(mockService.listRepositoriesForOrganisation(org)) + .thenReturn(listOf(Repo(id, repo, topContributor = contributor))) + whenever(mockService.listContributorsForRepository(org, repo)) + .thenReturn(null) + viewModel.callService() + } + + verify(observer).onChanged(Data.Loading) + verify(observer).onChanged( + Data.Success( + listOf(Repo(id, repo)) + ) + ) + viewModel.getData().removeObserver(observer) + } + + @Test + fun `should update state according to disabled mode`() { + val observer = mock>() + viewModel.getState().observeForever(observer) + + viewModel.setMode(MockResponseInterceptor.Mode.DISABLED) + + verify(observer).onChanged(State.Message(R.string.disabled_description)) + } + + @Test + fun `should update state according to enabled mode`() { + val observer = mock>() + viewModel.getState().observeForever(observer) + + viewModel.setMode(MockResponseInterceptor.Mode.ENABLED) + + verify(observer).onChanged(State.Message(R.string.enabled_description)) + } + + @Test + fun `should update state according to mixed mode`() { + val observer = mock>() + viewModel.getState().observeForever(observer) + + viewModel.setMode(MockResponseInterceptor.Mode.MIXED) + + verify(observer).onChanged(State.Message(R.string.mixed_description)) + } + + @Test + fun `should check permission and update state according to record mode`() { + val observer = mock>() + viewModel.getState().observeForever(observer) + + viewModel.setMode(MockResponseInterceptor.Mode.RECORD) + + verify(observer).onChanged(State.Permission) + verify(observer).onChanged(State.Message(R.string.record_description)) + } +} diff --git a/demo/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/demo/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 00000000..ca6ee9ce --- /dev/null +++ b/demo/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline \ No newline at end of file diff --git a/gradle/versions.gradle b/gradle/versions.gradle index 1645ebd5..04590341 100644 --- a/gradle/versions.gradle +++ b/gradle/versions.gradle @@ -30,4 +30,6 @@ ext { artifactory_version = '4.9.6' slf4j_version = '1.7.26' junit_version = '5.4.2' + mockito_version = '2.27.0' + mockito_kotlin_version = '2.1.0' } \ No newline at end of file diff --git a/tests/build.gradle b/tests/build.gradle index 012fbc46..f010864e 100644 --- a/tests/build.gradle +++ b/tests/build.gradle @@ -52,8 +52,8 @@ dependencies { testImplementation "org.junit.jupiter:junit-jupiter-api:$junit_version" testImplementation "org.junit.jupiter:junit-jupiter-params:$junit_version" testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junit_version" - testImplementation "org.mockito:mockito-core:2.27.0" - testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.1.0" + testImplementation "org.mockito:mockito-core:$mockito_version" + testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:$mockito_kotlin_version" testImplementation "org.slf4j:slf4j-simple:$slf4j_version" From 012d9abdd59b713ab3e5f9086364e1d0bc651a4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ste=CC=81phane=20Baiget?= Date: Fri, 19 Jul 2019 08:09:54 +0200 Subject: [PATCH 07/32] Move to Android JetPack ViewModel. --- demo/build.gradle | 20 ++- .../httpmocker/demo/di/InjectionModule.kt | 10 +- .../httpmocker/demo/ui/MainActivity.kt | 52 +++++-- .../httpmocker/demo/ui/MainContract.kt | 37 ----- .../httpmocker/demo/ui/MainPresenter.kt | 84 ----------- .../speekha/httpmocker/demo/ui/MainScope.kt | 30 ---- .../httpmocker/demo/ui/MainViewModel.kt | 86 +++++++++++ demo/src/main/res/layout/activity_main.xml | 27 +++- .../httpmocker/demo/ui/CoroutinesTestRule.kt | 26 ++++ .../httpmocker/demo/ui/MainViewModelTest.kt | 134 ++++++++++++++++++ .../org.mockito.plugins.MockMaker | 1 + gradle/versions.gradle | 2 + tests/build.gradle | 4 +- 13 files changed, 329 insertions(+), 184 deletions(-) delete mode 100644 demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainContract.kt delete mode 100644 demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainPresenter.kt delete mode 100644 demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainScope.kt create mode 100644 demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainViewModel.kt create mode 100644 demo/src/test/java/fr/speekha/httpmocker/demo/ui/CoroutinesTestRule.kt create mode 100644 demo/src/test/java/fr/speekha/httpmocker/demo/ui/MainViewModelTest.kt create mode 100644 demo/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker diff --git a/demo/build.gradle b/demo/build.gradle index 3dfa103d..38dbf1f0 100644 --- a/demo/build.gradle +++ b/demo/build.gradle @@ -15,9 +15,7 @@ */ apply plugin: 'com.android.application' - apply plugin: 'kotlin-android' - apply plugin: 'kotlin-android-extensions' @@ -47,7 +45,10 @@ android { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } - buildToolsVersion '28.0.3' + testOptions { + unitTests.returnDefaultValues = true + } + buildToolsVersion '29.0.1' } //repositories { @@ -56,12 +57,13 @@ android { //} dependencies { - implementation fileTree(include: ['*.jar'], dir: 'libs') - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" implementation "androidx.appcompat:appcompat:$support_version" + implementation "androidx.core:core-ktx:1.0.2" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.1.0-rc01" implementation 'androidx.constraintlayout:constraintlayout:1.1.3' implementation 'androidx.recyclerview:recyclerview:1.0.0' implementation 'com.google.android.material:material:1.1.0-alpha08' @@ -69,9 +71,15 @@ dependencies { implementation "com.squareup.retrofit2:retrofit:$retrofit_version" implementation "com.squareup.retrofit2:converter-jackson:$retrofit_version" - implementation "org.koin:koin-android:2.0.1" + implementation "org.koin:koin-androidx-viewmodel:2.0.1" implementation "org.slf4j:slf4j-android:$slf4j_version" //implementation "fr.speekha.httpmocker:jackson-adapter:$httpmock_version" implementation project(':jackson-adapter') + + testImplementation "junit:junit:4.12" + testImplementation "org.mockito:mockito-core:$mockito_version" + testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:$mockito_kotlin_version" + testImplementation "androidx.arch.core:core-testing:2.0.1" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" } diff --git a/demo/src/main/java/fr/speekha/httpmocker/demo/di/InjectionModule.kt b/demo/src/main/java/fr/speekha/httpmocker/demo/di/InjectionModule.kt index fdf977b8..d22116dd 100644 --- a/demo/src/main/java/fr/speekha/httpmocker/demo/di/InjectionModule.kt +++ b/demo/src/main/java/fr/speekha/httpmocker/demo/di/InjectionModule.kt @@ -20,11 +20,11 @@ import android.content.Context import android.os.Environment import fr.speekha.httpmocker.MockResponseInterceptor import fr.speekha.httpmocker.demo.service.GithubApiEndpoints -import fr.speekha.httpmocker.demo.ui.MainContract -import fr.speekha.httpmocker.demo.ui.MainPresenter +import fr.speekha.httpmocker.demo.ui.MainViewModel import fr.speekha.httpmocker.jackson.JacksonMapper import fr.speekha.httpmocker.policies.MirrorPathPolicy import okhttp3.OkHttpClient +import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.core.module.Module import org.koin.dsl.module import retrofit2.Retrofit @@ -62,5 +62,7 @@ val injectionModule: Module = module { get().create(GithubApiEndpoints::class.java) } - factory { (view: MainContract.View) -> MainPresenter(view, get(), get()) } -} \ No newline at end of file + viewModel { + MainViewModel(get(), get()) + } +} diff --git a/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainActivity.kt b/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainActivity.kt index 18bcd9cd..647f491c 100644 --- a/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainActivity.kt +++ b/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainActivity.kt @@ -20,30 +20,48 @@ import android.Manifest.permission.WRITE_EXTERNAL_STORAGE import android.content.pm.PackageManager import android.os.Build import android.os.Bundle -import androidx.annotation.IntegerRes +import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat +import androidx.core.view.isVisible +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer import androidx.recyclerview.widget.LinearLayoutManager import fr.speekha.httpmocker.MockResponseInterceptor import fr.speekha.httpmocker.demo.R import fr.speekha.httpmocker.demo.model.Repo import kotlinx.android.synthetic.main.activity_main.* -import org.koin.android.ext.android.inject -import org.koin.core.parameter.parametersOf +import org.koin.androidx.viewmodel.ext.android.viewModel -class MainActivity : AppCompatActivity(), MainContract.View { - private val presenter: MainContract.Presenter by inject { parametersOf(this) } +class MainActivity : AppCompatActivity() { + private val viewModel by viewModel() private val adapter = RepoAdapter(this) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) + observe(viewModel.getData()) { data -> + when (data) { + is Data.Loading -> showLoading(true) + is Data.Success -> setResult(data.repos) + is Data.Error -> setError(data.message) + } + } + + observe(viewModel.getState()) { state -> + when (state) { + is State.Permission -> checkPermission() + is State.Message -> updateDescriptionLabel(state.message) + } + } + radioState.addOnButtonCheckedListener { _, checkedId, isChecked -> if (isChecked) { - presenter.setMode( + viewModel.setMode( when (checkedId) { R.id.stateEnabled -> MockResponseInterceptor.Mode.ENABLED R.id.stateMixed -> MockResponseInterceptor.Mode.MIXED @@ -57,36 +75,42 @@ class MainActivity : AppCompatActivity(), MainContract.View { } btnCall.setOnClickListener { - presenter.callService() + viewModel.callService() } results.adapter = adapter results.layoutManager = LinearLayoutManager(this) } - override fun onStop() { - super.onStop() - presenter.stop() + private fun showLoading(visible: Boolean) { + results.isVisible = !visible + loader.isVisible = visible } - override fun setResult(result: List) { + private fun setResult(result: List) { + showLoading(false) adapter.repos = result adapter.notifyDataSetChanged() } - override fun setError(message: String?) { + private fun setError(message: String?) { + showLoading(false) adapter.repos = null adapter.errorMessage = message adapter.notifyDataSetChanged() } - override fun checkPermission() { + private fun checkPermission() { if (Build.VERSION.SDK_INT >= 23 && checkSelfPermission(WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, arrayOf(WRITE_EXTERNAL_STORAGE), 1) } } - override fun updateDescriptionLabel(@IntegerRes resId: Int) { + private fun updateDescriptionLabel(@StringRes resId: Int) { tvMessage.setText(resId) } } + +fun > LifecycleOwner.observe(liveData: L, body: (T?) -> Unit) { + liveData.observe(this, Observer(body)) +} diff --git a/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainContract.kt b/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainContract.kt deleted file mode 100644 index 999e7f72..00000000 --- a/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainContract.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright 2019 David Blanc - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.speekha.httpmocker.demo.ui - -import androidx.annotation.IntegerRes -import fr.speekha.httpmocker.MockResponseInterceptor -import fr.speekha.httpmocker.demo.model.Repo - -interface MainContract { - - interface View { - fun setResult(result: List) - fun setError(message: String?) - fun checkPermission() - fun updateDescriptionLabel(@IntegerRes resId: Int) - } - - interface Presenter { - fun stop() - fun callService() - fun setMode(mode: MockResponseInterceptor.Mode) - } -} \ No newline at end of file diff --git a/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainPresenter.kt b/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainPresenter.kt deleted file mode 100644 index 63391477..00000000 --- a/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainPresenter.kt +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright 2019 David Blanc - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.speekha.httpmocker.demo.ui - -import android.util.Log -import fr.speekha.httpmocker.MockResponseInterceptor -import fr.speekha.httpmocker.demo.R -import fr.speekha.httpmocker.demo.service.GithubApiEndpoints -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - - -class MainPresenter( - private val view: MainContract.View, - private val apiService: GithubApiEndpoints, - private val mocker: MockResponseInterceptor -) : MainContract.Presenter, CoroutineScope by MainScope() { - - override fun callService() { - launch { - try { - val org = "kotlin" - val repos = loadRepos(org) - .map { - val contributor = loadTopContributor(org, it.name)?.firstOrNull() - it.copy(topContributor = contributor?.run { "$login - $contributions contributions" }) - } - view.setResult(repos) - } catch (e: Throwable) { - view.setError(e.message) - } - } - } - - private suspend fun loadRepos(org: String) = withContext(Dispatchers.IO) { - apiService.listRepositoriesForOrganisation(org) - } - - private suspend fun loadTopContributor(org: String, repo: String) = - withContext(Dispatchers.IO) { - try { - apiService.listContributorsForRepository(org, repo) - } catch (e: Throwable) { - Log.e("Presenter", e.message, e) - null - } - } - - override fun setMode(mode: MockResponseInterceptor.Mode) { - mocker.mode = mode - if (mocker.mode == MockResponseInterceptor.Mode.RECORD) { - view.checkPermission() - } - view.updateDescriptionLabel( - when (mocker.mode) { - MockResponseInterceptor.Mode.DISABLED -> R.string.disabled_description - MockResponseInterceptor.Mode.ENABLED -> R.string.enabled_description - MockResponseInterceptor.Mode.MIXED -> R.string.mixed_description - MockResponseInterceptor.Mode.RECORD -> R.string.record_description - } - ) - } - - override fun stop() { - coroutineContext.cancel() - } -} \ No newline at end of file diff --git a/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainScope.kt b/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainScope.kt deleted file mode 100644 index f3537577..00000000 --- a/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainScope.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright 2019 David Blanc - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.speekha.httpmocker.demo.ui - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlin.coroutines.CoroutineContext - -class MainScope : CoroutineScope { - - val job = Job() - - override val coroutineContext: CoroutineContext = Dispatchers.Main + job - -} diff --git a/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainViewModel.kt b/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainViewModel.kt new file mode 100644 index 00000000..db54c533 --- /dev/null +++ b/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainViewModel.kt @@ -0,0 +1,86 @@ +package fr.speekha.httpmocker.demo.ui + +import android.util.Log +import androidx.annotation.StringRes +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import fr.speekha.httpmocker.MockResponseInterceptor +import fr.speekha.httpmocker.demo.R +import fr.speekha.httpmocker.demo.model.Repo +import fr.speekha.httpmocker.demo.service.GithubApiEndpoints +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class MainViewModel( + private val apiService: GithubApiEndpoints, + private val mocker: MockResponseInterceptor +) : ViewModel() { + + private val data = MutableLiveData() + private val state = MutableLiveData() + private val exceptionHandler = CoroutineExceptionHandler { _, exception -> + data.postValue(Data.Error(exception.message)) + } + + fun getData(): LiveData = data + fun getState(): LiveData = state + + fun callService() { + viewModelScope.launch(exceptionHandler) { + data.postValue(Data.Loading) + val org = "kotlin" + val repos = loadRepos(org) + .map { + val contributor = loadTopContributor(org, it.name)?.firstOrNull() + it.copy(topContributor = contributor?.run { "$login - $contributions contributions" }) + } + data.postValue(Data.Success(repos)) + } + } + + fun setMode(mode: MockResponseInterceptor.Mode) { + mocker.mode = mode + if (mocker.mode == MockResponseInterceptor.Mode.RECORD) { + state.postValue(State.Permission) + } + state.postValue( + State.Message( + when (mocker.mode) { + MockResponseInterceptor.Mode.DISABLED -> R.string.disabled_description + MockResponseInterceptor.Mode.ENABLED -> R.string.enabled_description + MockResponseInterceptor.Mode.MIXED -> R.string.mixed_description + MockResponseInterceptor.Mode.RECORD -> R.string.record_description + } + ) + ) + } + + private suspend fun loadRepos(org: String) = withContext(Dispatchers.IO) { + apiService.listRepositoriesForOrganisation(org) + } + + private suspend fun loadTopContributor(org: String, repo: String) = + withContext(Dispatchers.IO) { + try { + apiService.listContributorsForRepository(org, repo) + } catch (e: Throwable) { + Log.e("ViewModel", e.message, e) + null + } + } +} + +sealed class Data { + object Loading : Data() + data class Success(val repos: List) : Data() + data class Error(val message: String?) : Data() +} + +sealed class State { + data class Message(@StringRes val message: Int) : State() + object Permission : State() +} diff --git a/demo/src/main/res/layout/activity_main.xml b/demo/src/main/res/layout/activity_main.xml index 47f40764..149a115d 100644 --- a/demo/src/main/res/layout/activity_main.xml +++ b/demo/src/main/res/layout/activity_main.xml @@ -23,16 +23,17 @@ tools:context=".ui.MainActivity" > - + + () + private val mockResponseInterceptor = + MockResponseInterceptor.Builder().parseScenariosWith(mock()).build() + private lateinit var viewModel: MainViewModel + + @Before + fun setup() { + viewModel = MainViewModel(mockService, mockResponseInterceptor) + } + + @Test + fun `should load repos and top contributors successfully`() { + val observer = mock>() + viewModel.getData().observeForever(observer) + + coroutinesTestRule.testDispatcher.runBlockingTest { + whenever(mockService.listRepositoriesForOrganisation(org)) + .thenReturn(listOf(Repo(id, repo, topContributor = contributor))) + whenever(mockService.listContributorsForRepository(org, repo)) + .thenReturn(listOf(User(login = contributor, contributions = contributions))) + viewModel.callService() + } + + verify(observer).onChanged(Data.Loading) + verify(observer).onChanged( + Data.Success( + listOf( + Repo( + id, + repo, + topContributor = "$contributor - $contributions contributions" + ) + ) + ) + ) + viewModel.getData().removeObserver(observer) + } + + @Test + fun `should load repos successfully and fail top contributors`() { + val observer = mock>() + viewModel.getData().observeForever(observer) + + coroutinesTestRule.testDispatcher.runBlockingTest { + whenever(mockService.listRepositoriesForOrganisation(org)) + .thenReturn(listOf(Repo(id, repo, topContributor = contributor))) + whenever(mockService.listContributorsForRepository(org, repo)) + .thenReturn(null) + viewModel.callService() + } + + verify(observer).onChanged(Data.Loading) + verify(observer).onChanged( + Data.Success( + listOf(Repo(id, repo)) + ) + ) + viewModel.getData().removeObserver(observer) + } + + @Test + fun `should update state according to disabled mode`() { + val observer = mock>() + viewModel.getState().observeForever(observer) + + viewModel.setMode(MockResponseInterceptor.Mode.DISABLED) + + verify(observer).onChanged(State.Message(R.string.disabled_description)) + } + + @Test + fun `should update state according to enabled mode`() { + val observer = mock>() + viewModel.getState().observeForever(observer) + + viewModel.setMode(MockResponseInterceptor.Mode.ENABLED) + + verify(observer).onChanged(State.Message(R.string.enabled_description)) + } + + @Test + fun `should update state according to mixed mode`() { + val observer = mock>() + viewModel.getState().observeForever(observer) + + viewModel.setMode(MockResponseInterceptor.Mode.MIXED) + + verify(observer).onChanged(State.Message(R.string.mixed_description)) + } + + @Test + fun `should check permission and update state according to record mode`() { + val observer = mock>() + viewModel.getState().observeForever(observer) + + viewModel.setMode(MockResponseInterceptor.Mode.RECORD) + + verify(observer).onChanged(State.Permission) + verify(observer).onChanged(State.Message(R.string.record_description)) + } +} diff --git a/demo/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/demo/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 00000000..ca6ee9ce --- /dev/null +++ b/demo/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline \ No newline at end of file diff --git a/gradle/versions.gradle b/gradle/versions.gradle index 1645ebd5..04590341 100644 --- a/gradle/versions.gradle +++ b/gradle/versions.gradle @@ -30,4 +30,6 @@ ext { artifactory_version = '4.9.6' slf4j_version = '1.7.26' junit_version = '5.4.2' + mockito_version = '2.27.0' + mockito_kotlin_version = '2.1.0' } \ No newline at end of file diff --git a/tests/build.gradle b/tests/build.gradle index 012fbc46..f010864e 100644 --- a/tests/build.gradle +++ b/tests/build.gradle @@ -52,8 +52,8 @@ dependencies { testImplementation "org.junit.jupiter:junit-jupiter-api:$junit_version" testImplementation "org.junit.jupiter:junit-jupiter-params:$junit_version" testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junit_version" - testImplementation "org.mockito:mockito-core:2.27.0" - testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.1.0" + testImplementation "org.mockito:mockito-core:$mockito_version" + testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:$mockito_kotlin_version" testImplementation "org.slf4j:slf4j-simple:$slf4j_version" From 5531dc7196f38086cae55404f5a26c68c3688f4a Mon Sep 17 00:00:00 2001 From: David Blanc Date: Tue, 23 Jul 2019 14:39:37 +0200 Subject: [PATCH 08/32] Update CI for demo tests --- .circleci/config.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index c528d599..19f65ed7 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -219,12 +219,14 @@ jobs: - build-kotlinx-adapter-{{ .Branch }}-{{ .Revision }} - run: name: Tests - command: ./gradlew tests:test --stacktrace + command: ./gradlew tests:test demo:test --stacktrace - store_artifacts: path: tests/build/reports destination: reports - store_test_results: path: tests/build/test-results + - store_test_results: + path: demo/build/test-results - save_cache: key: build-{{ .Branch }}-{{ .Revision }} paths: From cb1be208cbae828e1c94433742c743ca11e9b4cc Mon Sep 17 00:00:00 2001 From: David Blanc Date: Sat, 27 Jul 2019 21:56:33 +0200 Subject: [PATCH 09/32] Allow to throw errors if recording fails --- .../httpmocker/MockResponseInterceptor.kt | 13 +++++- .../fr/speekha/httpmocker/RequestRecorder.kt | 41 ++++++++++++++----- .../httpmocker/interceptor/RecordTests.kt | 40 +++++++++++++----- 3 files changed, 71 insertions(+), 23 deletions(-) diff --git a/mocker/src/main/kotlin/fr/speekha/httpmocker/MockResponseInterceptor.kt b/mocker/src/main/kotlin/fr/speekha/httpmocker/MockResponseInterceptor.kt index cddeb8df..3f5413d4 100644 --- a/mocker/src/main/kotlin/fr/speekha/httpmocker/MockResponseInterceptor.kt +++ b/mocker/src/main/kotlin/fr/speekha/httpmocker/MockResponseInterceptor.kt @@ -176,6 +176,7 @@ private constructor( private var simulatedDelay: Long = 0 private var interceptorMode: Mode = Mode.DISABLED private val dynamicCallbacks = mutableListOf() + private var showSavingErrors = false /** * For static mocks: Defines the policy used to retrieve the configuration files based @@ -242,6 +243,15 @@ private constructor( root = folder } + /** + * Allows to return an error if saving fails when recording. + * @param failOnError if true, failure to save scenarios will throw an exception. + * If false, saving exceptions will be ignored. + */ + fun failOnRecordingError(failOnError: Boolean) = apply { + showSavingErrors = failOnError + } + /** * Allows to set a fake delay for every requests (can be overridden in a scenario) to * achieve a more realistic behavior (probably necessary if you want to display loading @@ -266,7 +276,7 @@ private constructor( */ fun build(): MockResponseInterceptor = MockResponseInterceptor( buildProviders(), - mapper?.let { RequestRecorder(it, filingPolicy, root) }).apply { + mapper?.let { RequestRecorder(it, filingPolicy, root, showSavingErrors) }).apply { if (interceptorMode == Mode.RECORD && root == null) { error(NO_ROOT_FOLDER_ERROR) } @@ -287,6 +297,7 @@ private constructor( StaticMockProvider(filingPolicy, loader, jsonMapper) } else null } + } } diff --git a/mocker/src/main/kotlin/fr/speekha/httpmocker/RequestRecorder.kt b/mocker/src/main/kotlin/fr/speekha/httpmocker/RequestRecorder.kt index c6ac384f..9eb87333 100644 --- a/mocker/src/main/kotlin/fr/speekha/httpmocker/RequestRecorder.kt +++ b/mocker/src/main/kotlin/fr/speekha/httpmocker/RequestRecorder.kt @@ -28,25 +28,33 @@ import java.io.OutputStream internal class RequestRecorder( private val mapper: Mapper, private val filingPolicy: FilingPolicy, - private val rootFolder: File? + private val rootFolder: File?, + private val failOnError: Boolean ) { private val logger = getLogger() private val extensionMappings: Map by lazy { loadExtensionMap() } - fun saveFiles(record: CallRecord) = try { - val requestFile = File(rootFolder, filingPolicy.getPath(record.request)) - logger.debug("Saving scenario file $requestFile") - val matchers = buildMatcherList(record, requestFile) - saveRequestFile(requestFile, matchers) - matchers.last().response.bodyFile?.let { responseFile -> - saveResponseBody(File(requestFile.parentFile, responseFile), record.body) + fun saveFiles(record: CallRecord) { + try { + val requestFile = getRequestFilePath(record) + val matchers = buildMatcherList(record, requestFile) + saveRequestFile(requestFile, matchers) + saveResponseBody(matchers, requestFile, record) + } catch (e: Throwable) { + logger.error("Error while writing scenario", e) + if (failOnError) { + throw e + } } - } catch (e: Throwable) { - logger.error("Error while writing scenario", e) } + private fun getRequestFilePath(record: CallRecord): File = + File(rootFolder, filingPolicy.getPath(record.request)).also { + logger.debug("Saving scenario file $it") + } + private fun buildMatcherList(record: CallRecord, requestFile: File): List = with(record) { val previousRecords: List = if (requestFile.exists()) @@ -71,7 +79,18 @@ internal class RequestRecorder( mapper.writeValue(it, matchers) } - private fun saveResponseBody(storeFile: File, body: ByteArray?) = body?.let { array -> + private fun saveResponseBody( + matchers: List, + requestFile: File, + record: CallRecord + ) = matchers.last().response.bodyFile?.let { responseFile -> + val storeFile = File(requestFile.parentFile, responseFile) + record.body?.let { array -> + saveBodyFile(storeFile, array) + } + } + + private fun saveBodyFile(storeFile: File, array: ByteArray) { logger.debug("Saving response body file ${storeFile.name}") writeFile(storeFile) { it.write(array) diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/RecordTests.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/RecordTests.kt index c34ecd04..8a0db2f6 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/RecordTests.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/RecordTests.kt @@ -25,11 +25,13 @@ import fr.speekha.httpmocker.model.RequestDescriptor import fr.speekha.httpmocker.model.ResponseDescriptor import okhttp3.OkHttpClient import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.assertThrows import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource import java.io.File +import java.io.FileNotFoundException import java.nio.file.Files import java.nio.file.Path import java.util.Collections @@ -46,7 +48,7 @@ class RecordTests : TestWithServer() { val response = executeGetRequest("record/request") assertResponseCode(response, 200, "OK") - Assertions.assertEquals("body", response.body()?.string()) + assertEquals("body", response.body()?.string()) } @ParameterizedTest(name = "{0}") @@ -56,12 +58,26 @@ class RecordTests : TestWithServer() { mapper: Mapper ) { enqueueServerResponse(200, "body") - setUpInterceptor(mapper, "") + setUpInterceptor(mapper, "", false) val response = executeGetRequest("record/request") assertResponseCode(response, 200, "OK") - Assertions.assertEquals("body", response.body()?.string()) + assertEquals("body", response.body()?.string()) + } + + @ParameterizedTest(name = "{0}") + @MethodSource("data") + fun `recording failure should return an error if desired`( + title: String, + mapper: Mapper + ) { + enqueueServerResponse(200, "body") + setUpInterceptor(mapper, "", true) + + assertThrows { + executeGetRequest("record/request") + } } @ParameterizedTest(name = "{0}") @@ -128,11 +144,11 @@ class RecordTests : TestWithServer() { ) ) ) - Assertions.assertEquals(listOf(expectedResult), result) + assertEquals(listOf(expectedResult), result) } withFile("$SAVE_FOLDER/request_body_0.txt") { - Assertions.assertEquals("body", it.readAsString()) + assertEquals("body", it.readAsString()) } } @@ -161,7 +177,7 @@ class RecordTests : TestWithServer() { ) ) ) - Assertions.assertEquals(listOf(expectedResult), result) + assertEquals(listOf(expectedResult), result) } } @@ -215,14 +231,14 @@ class RecordTests : TestWithServer() { ) ) ) - Assertions.assertEquals(expectedResult, result) + assertEquals(expectedResult, result) } withFile("$SAVE_FOLDER/request_body_0.txt") { - Assertions.assertEquals("body", it.readAsString()) + assertEquals("body", it.readAsString()) } withFile("$SAVE_FOLDER/request_body_1.txt") { - Assertions.assertEquals("second body", it.readAsString()) + assertEquals("second body", it.readAsString()) } } @@ -270,7 +286,8 @@ class RecordTests : TestWithServer() { private fun setUpInterceptor( mapper: Mapper, - rootFolder: String = SAVE_FOLDER + rootFolder: String = SAVE_FOLDER, + failOnError: Boolean = false ) { interceptor = MockResponseInterceptor.Builder() .decodeScenarioPathWith { @@ -280,6 +297,7 @@ class RecordTests : TestWithServer() { } .parseScenariosWith(mapper) .saveScenariosIn(File(rootFolder)) + .failOnRecordingError(failOnError) .setInterceptorStatus(RECORD) .build() From 904dca42853f738d1f33838653f614a260bf98a1 Mon Sep 17 00:00:00 2001 From: David Blanc Date: Sat, 27 Jul 2019 23:11:14 +0200 Subject: [PATCH 10/32] Support exact matches --- .../httpmocker/custom/JsonSerialization.kt | 1 + .../httpmocker/custom/JsonStringReader.kt | 13 +++++ .../httpmocker/custom/RequestAdapter.kt | 1 + .../fr/speekha/httpmocker/gson/GsonMapper.kt | 4 +- .../httpmocker/gson/RequestDescriptor.kt | 5 ++ .../httpmocker/jackson/JacksonMapper.kt | 4 +- .../httpmocker/jackson/RequestDescriptor.kt | 3 + .../httpmocker/kotlinx/KotlinxMapper.kt | 1 + .../httpmocker/kotlinx/RequestDescriptor.kt | 4 ++ .../httpmocker/model/RequestDescriptor.kt | 5 ++ .../httpmocker/scenario/StaticMockProvider.kt | 5 +- .../httpmocker/moshi/MatcherAdapter.kt | 4 +- .../speekha/httpmocker/moshi/MoshiMapper.kt | 1 + .../httpmocker/moshi/RequestDescriptor.kt | 4 ++ .../httpmocker/interceptor/StaticMockTests.kt | 11 ++++ .../mappers/JsonStringReaderTest.kt | 55 ++++++++++++++++--- .../fr/speekha/httpmocker/mappers/TestData.kt | 1 + tests/src/test/resources/complete_input.json | 1 + tests/src/test/resources/exact_match.json | 13 +++++ 19 files changed, 119 insertions(+), 17 deletions(-) create mode 100644 tests/src/test/resources/exact_match.json diff --git a/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonSerialization.kt b/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonSerialization.kt index 1f160db3..6748218b 100644 --- a/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonSerialization.kt +++ b/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonSerialization.kt @@ -39,6 +39,7 @@ internal fun Matcher.toJson(): String = """ { """ internal fun RequestDescriptor.toJson(): String = listOf( + "exact-match" to exactMatch.takeIf { it }, "protocol" to protocol.wrap(), "method" to method.wrap(), "host" to host.wrap(), diff --git a/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonStringReader.kt b/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonStringReader.kt index e3298c2a..f6955191 100644 --- a/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonStringReader.kt +++ b/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonStringReader.kt @@ -149,6 +149,17 @@ class JsonStringReader( return adapter.fromJson(this) } + fun readBoolean(): Boolean { + val resultTrue = json.substring(index).trimStart().startsWith("true") + val resultFalse = json.substring(index).trimStart().startsWith("false") + if (!resultTrue && !resultFalse) { + parseError(INVALID_BOOLEAN_ERROR) + } + index = if (resultTrue) json.indexOf("true", index) + 4 + else json.indexOf("false", index) + 5 + return resultTrue + } + private fun extractStringLiteral(): String { val start = json.indexOf("\"", index) val match = Regex("[^\\\\]\"").find(json, start) @@ -177,6 +188,7 @@ class JsonStringReader( error("$message${extractAfterCurrentPosition()}") private fun extractAfterCurrentPosition() = json.substring(index).truncate(10) + } const val WRONG_START_OF_OBJECT_ERROR = "No object starts here: " @@ -187,4 +199,5 @@ const val WRONG_END_OF_LIST_ERROR = "List is not entirely processed: " const val WRONG_START_OF_STRING_ERROR = "No string starts here: " const val WRONG_START_OF_STRING_FIELD_ERROR = "Not ready to read a string value for a field: " const val INVALID_NUMBER_ERROR = "Invalid numeric value: " +const val INVALID_BOOLEAN_ERROR = "Invalid boolean value: " diff --git a/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/RequestAdapter.kt b/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/RequestAdapter.kt index 83f68877..9e09097b 100644 --- a/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/RequestAdapter.kt +++ b/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/RequestAdapter.kt @@ -26,6 +26,7 @@ internal class RequestAdapter : BaseObjectAdapter() { reader: JsonStringReader, builder: RequestDescriptor ): RequestDescriptor = when (val field = reader.readFieldName()) { + "exact-match" -> builder.copy(exactMatch= reader.readBoolean()) "protocol" -> builder.copy(protocol = reader.readString()) "method" -> builder.copy(method = reader.readString()) "port" -> builder.copy(port = reader.readInt()) diff --git a/gson-adapter/src/main/kotlin/fr/speekha/httpmocker/gson/GsonMapper.kt b/gson-adapter/src/main/kotlin/fr/speekha/httpmocker/gson/GsonMapper.kt index f3fd4000..4c83e4a8 100644 --- a/gson-adapter/src/main/kotlin/fr/speekha/httpmocker/gson/GsonMapper.kt +++ b/gson-adapter/src/main/kotlin/fr/speekha/httpmocker/gson/GsonMapper.kt @@ -62,10 +62,10 @@ class GsonMapper : Mapper { Matcher(request?.toModel() ?: RequestDescriptor(), response.toModel()) private fun JsonRequestDescriptor.toModel() = - RequestDescriptor(protocol, method, host, port, path, headers.toModel(), params, body) + RequestDescriptor(exactMatch ?: false, protocol, method, host, port, path, headers.toModel(), params, body) private fun RequestDescriptor.fromModel() = - JsonRequestDescriptor(protocol, method, host, port, path, getHeaders(), params, body) + JsonRequestDescriptor(exactMatch.takeIf { it }, protocol, method, host, port, path, getHeaders(), params, body) private fun RequestDescriptor.getHeaders() = HeaderAdapter.HeaderList(headers.map { it.fromModel() }) diff --git a/gson-adapter/src/main/kotlin/fr/speekha/httpmocker/gson/RequestDescriptor.kt b/gson-adapter/src/main/kotlin/fr/speekha/httpmocker/gson/RequestDescriptor.kt index aa59e6a5..c34d32e9 100644 --- a/gson-adapter/src/main/kotlin/fr/speekha/httpmocker/gson/RequestDescriptor.kt +++ b/gson-adapter/src/main/kotlin/fr/speekha/httpmocker/gson/RequestDescriptor.kt @@ -16,8 +16,13 @@ package fr.speekha.httpmocker.gson +import com.google.gson.annotations.SerializedName + internal data class RequestDescriptor( + @SerializedName("exact-match") + val exactMatch: Boolean? = null, + val protocol: String? = null, val method: String? = null, diff --git a/jackson-adapter/src/main/kotlin/fr/speekha/httpmocker/jackson/JacksonMapper.kt b/jackson-adapter/src/main/kotlin/fr/speekha/httpmocker/jackson/JacksonMapper.kt index 74517db2..7c1883a9 100644 --- a/jackson-adapter/src/main/kotlin/fr/speekha/httpmocker/jackson/JacksonMapper.kt +++ b/jackson-adapter/src/main/kotlin/fr/speekha/httpmocker/jackson/JacksonMapper.kt @@ -60,10 +60,10 @@ private fun Matcher.fromModel() = JsonMatcher(request.fromModel(), response.from private fun JsonMatcher.toModel() = Matcher(request.toModel(), response.toModel()) private fun JsonRequestDescriptor.toModel() = - RequestDescriptor(protocol, method, host, port, path, headers.map { it.toModel() }, params, body) + RequestDescriptor(exactMatch ?: false, protocol, method, host, port, path, headers.map { it.toModel() }, params, body) private fun RequestDescriptor.fromModel() = - JsonRequestDescriptor(protocol, method, host, port, path, headers.map { it.fromModel() }, params, body) + JsonRequestDescriptor(exactMatch.takeIf { it }, protocol, method, host, port, path, headers.map { it.fromModel() }, params, body) private fun JsonHeader.toModel() = Header(name, value) diff --git a/jackson-adapter/src/main/kotlin/fr/speekha/httpmocker/jackson/RequestDescriptor.kt b/jackson-adapter/src/main/kotlin/fr/speekha/httpmocker/jackson/RequestDescriptor.kt index 9daa54a2..5a3cc0c0 100644 --- a/jackson-adapter/src/main/kotlin/fr/speekha/httpmocker/jackson/RequestDescriptor.kt +++ b/jackson-adapter/src/main/kotlin/fr/speekha/httpmocker/jackson/RequestDescriptor.kt @@ -27,6 +27,9 @@ internal data class RequestDescriptor @JsonCreator constructor( + @JsonProperty("exact-match") + val exactMatch: Boolean? = null, + @JsonProperty("protocol") val protocol: String? = null, diff --git a/kotlinx-adapter/src/main/kotlin/fr/speekha/httpmocker/kotlinx/KotlinxMapper.kt b/kotlinx-adapter/src/main/kotlin/fr/speekha/httpmocker/kotlinx/KotlinxMapper.kt index e9bb40c5..0cd7fa8d 100644 --- a/kotlinx-adapter/src/main/kotlin/fr/speekha/httpmocker/kotlinx/KotlinxMapper.kt +++ b/kotlinx-adapter/src/main/kotlin/fr/speekha/httpmocker/kotlinx/KotlinxMapper.kt @@ -72,6 +72,7 @@ private fun JsonElement.toMatcher(): Matcher = private fun JsonElement?.toRequest(): RequestDescriptor = this?.run { RequestDescriptor( + jsonObject["exact-match"]?.primitive?.boolean ?: false, jsonObject["protocol"]?.asLiteral(), jsonObject["method"]?.asLiteral(), jsonObject["host"]?.asLiteral(), diff --git a/kotlinx-adapter/src/main/kotlin/fr/speekha/httpmocker/kotlinx/RequestDescriptor.kt b/kotlinx-adapter/src/main/kotlin/fr/speekha/httpmocker/kotlinx/RequestDescriptor.kt index 41973640..1c35f9ba 100644 --- a/kotlinx-adapter/src/main/kotlin/fr/speekha/httpmocker/kotlinx/RequestDescriptor.kt +++ b/kotlinx-adapter/src/main/kotlin/fr/speekha/httpmocker/kotlinx/RequestDescriptor.kt @@ -16,11 +16,14 @@ package fr.speekha.httpmocker.kotlinx +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import fr.speekha.httpmocker.model.RequestDescriptor as Model @Serializable internal data class RequestDescriptor( + @SerialName("exact-match") + val exactMatch: Boolean? = null, val protocol: String? = null, val method: String? = null, val host: String? = null, @@ -31,6 +34,7 @@ internal data class RequestDescriptor( val body: String? = null ) { constructor(model: Model) : this( + model.exactMatch.takeIf { it }, model.protocol, model.method, model.host, diff --git a/mocker/src/main/kotlin/fr/speekha/httpmocker/model/RequestDescriptor.kt b/mocker/src/main/kotlin/fr/speekha/httpmocker/model/RequestDescriptor.kt index b3a2154a..37076a7f 100644 --- a/mocker/src/main/kotlin/fr/speekha/httpmocker/model/RequestDescriptor.kt +++ b/mocker/src/main/kotlin/fr/speekha/httpmocker/model/RequestDescriptor.kt @@ -21,6 +21,11 @@ package fr.speekha.httpmocker.model */ data class RequestDescriptor( + /** + * Request has to match exactly (extra parameters implies a failure) + */ + val exactMatch: Boolean = false, + /** * Protocol (HTTP, HTTPS) */ diff --git a/mocker/src/main/kotlin/fr/speekha/httpmocker/scenario/StaticMockProvider.kt b/mocker/src/main/kotlin/fr/speekha/httpmocker/scenario/StaticMockProvider.kt index d35eef01..da5d43d8 100644 --- a/mocker/src/main/kotlin/fr/speekha/httpmocker/scenario/StaticMockProvider.kt +++ b/mocker/src/main/kotlin/fr/speekha/httpmocker/scenario/StaticMockProvider.kt @@ -53,7 +53,7 @@ internal class StaticMockProvider( private fun RequestDescriptor.match(request: Request): Boolean = (protocol?.equals(request.url().scheme(), true) ?: true) && - (method?.equals(request.method(), true) ?: true) && + (method?.equals(request.method(), true) ?: true) && (host?.equals(request.url().host(), true) ?: true) && (port?.let { it == request.url().port() } ?: true) && (path?.let { it == request.url().encodedPath() } ?: true) && @@ -62,7 +62,8 @@ internal class StaticMockProvider( (path?.let { it == request.url().encodedPath() } ?: true) && headers.all { request.headers(it.name).contains(it.value) } && params.all { request.url().queryParameter(it.key) == it.value } && - request.matchBody(this) + request.matchBody(this) && + (!exactMatch || (headers.size == request.headers().size() && params.size == request.url().querySize())) override fun loadResponseBody(request: Request, path: String): ByteArray? = loadFileContent(getRelativePath(filingPolicy.getPath(request), path))?.readBytes() diff --git a/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/MatcherAdapter.kt b/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/MatcherAdapter.kt index 1c4722a6..0c4567d2 100644 --- a/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/MatcherAdapter.kt +++ b/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/MatcherAdapter.kt @@ -39,10 +39,10 @@ internal class MatcherAdapter { } private fun requestFromJson(request: JsonRequestDescriptor) = - RequestDescriptor(request.protocol, request.method, request.host, request.port, request.path, request.headers.map { headerFromJson(it) }, request.params, request.body) + RequestDescriptor(request.exactMatch ?: false, request.protocol, request.method, request.host, request.port, request.path, request.headers.map { headerFromJson(it) }, request.params, request.body) private fun requestToJson(request: RequestDescriptor) = - JsonRequestDescriptor(request.protocol, request.method, request.host, request.port, request.path, request.headers.map { headerToJson(it) }, request.params, request.body) + JsonRequestDescriptor(request.exactMatch.takeIf { it }, request.protocol, request.method, request.host, request.port, request.path, request.headers.map { headerToJson(it) }, request.params, request.body) private fun responseFromJson(response: JsonResponseDescriptor) = ResponseDescriptor( response.delay, diff --git a/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/MoshiMapper.kt b/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/MoshiMapper.kt index 4d806a74..a5fad0d5 100644 --- a/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/MoshiMapper.kt +++ b/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/MoshiMapper.kt @@ -27,6 +27,7 @@ import fr.speekha.httpmocker.model.Matcher * A mapper using Moshi to serialize/deserialize scenarios. */ class MoshiMapper : Mapper { + private val adapter: JsonAdapter> init { diff --git a/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/RequestDescriptor.kt b/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/RequestDescriptor.kt index 5a334005..89e345cb 100644 --- a/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/RequestDescriptor.kt +++ b/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/RequestDescriptor.kt @@ -16,12 +16,16 @@ package fr.speekha.httpmocker.moshi +import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import fr.speekha.httpmocker.moshikotlin.Header @JsonClass(generateAdapter = true) internal data class RequestDescriptor( + @field:Json(name = "exact-match") + val exactMatch: Boolean? = null, + val protocol: String? = null, val method: String? = null, diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/StaticMockTests.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/StaticMockTests.kt index 42c4dd97..d88735d4 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/StaticMockTests.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/StaticMockTests.kt @@ -351,6 +351,17 @@ class StaticMockTests : TestWithServer() { assertEquals("no match", noMatch) } + + @ParameterizedTest(name = "{0}") + @MethodSource("data") + fun `should select response based on exact matches`(title: String, mapper: Mapper) { + setUpInterceptor(ENABLED, mapper) + + val response = executeGetRequest("/exact_match?param1=1¶m2=2") + + assertEquals(404, response.code()) + } + @ParameterizedTest(name = "{0}") @MethodSource("data") fun `should allow to delay all responses`(title: String, mapper: Mapper) { diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JsonStringReaderTest.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JsonStringReaderTest.kt index 68e069a3..ca106778 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JsonStringReaderTest.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JsonStringReaderTest.kt @@ -16,6 +16,7 @@ package fr.speekha.httpmocker.mappers +import fr.speekha.httpmocker.custom.INVALID_BOOLEAN_ERROR import fr.speekha.httpmocker.custom.INVALID_NUMBER_ERROR import fr.speekha.httpmocker.custom.JsonStringReader import fr.speekha.httpmocker.custom.ObjectAdapter @@ -150,6 +151,28 @@ class JsonStringReaderTest { assertEquals(result, reader.readLong()) } + @Test + fun `should read only boolean field in object`() { + val reader = JsonStringReader("{\"field1\": true , \"field2\": false }") + reader.beginObject() + reader.readFieldName() + val field1 = reader.readBoolean() + reader.next() + reader.readFieldName() + val field2 = reader.readBoolean() + assertEquals(true, field1) + assertEquals(false, field2) + } + + @Test + fun `should detect error on read boolean`() { + val reader = JsonStringReader("{\"field\": error }") + reader.beginObject() + reader.readFieldName() + val exception = assertThrows { reader.readBoolean() } + assertEquals("$INVALID_BOOLEAN_ERROR error }", exception.message) + } + @Test fun `should iterate through object`() { val reader = JsonStringReader(simpleObject) @@ -172,8 +195,10 @@ class JsonStringReaderTest { readString() next() val exception = assertThrows { endObject() } - assertEquals("$WRONG_END_OF_OBJECT_ERROR\n" + - " \"fie...", exception.message) + assertEquals( + "$WRONG_END_OF_OBJECT_ERROR\n" + + " \"fie...", exception.message + ) } } @@ -227,7 +252,12 @@ class JsonStringReaderTest { } endList() } - assertEquals(listOf(mapOf("field1" to "1", "field2" to "2"), mapOf("field1" to "1", "field2" to "2")), list) + assertEquals( + listOf( + mapOf("field1" to "1", "field2" to "2"), + mapOf("field1" to "1", "field2" to "2") + ), list + ) } @Test @@ -244,9 +274,11 @@ class JsonStringReaderTest { } endObject() val exception = assertThrows { endList() } - assertEquals("$WRONG_END_OF_LIST_ERROR,\n" + - " {\n" + - " ...", exception.message) + assertEquals( + "$WRONG_END_OF_LIST_ERROR,\n" + + " {\n" + + " ...", exception.message + ) } } @@ -281,7 +313,10 @@ class JsonStringReaderTest { obj[reader.readFieldName()] = reader.readString() obj[reader.readFieldName()] = reader.readObject(mapAdapter) reader.next() - assertEquals(mapOf("field0" to "0", "object" to mapOf("field1" to "1", "field2" to "2")), obj) + assertEquals( + mapOf("field0" to "0", "object" to mapOf("field1" to "1", "field2" to "2")), + obj + ) } @Test @@ -292,8 +327,10 @@ class JsonStringReaderTest { val exception = assertThrows { readObject(mapAdapter) } - assertEquals("$WRONG_START_OF_OBJECT_ERROR \"0\"\n" + - " ...", exception.message) + assertEquals( + "$WRONG_START_OF_OBJECT_ERROR \"0\"\n" + + " ...", exception.message + ) } } diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/TestData.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/TestData.kt index ed09b2dd..974c8b59 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/TestData.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/TestData.kt @@ -27,6 +27,7 @@ import java.io.InputStream internal val completeData = listOf( Matcher( RequestDescriptor( + exactMatch = true, protocol = "https", method = "post", host = "test.com", diff --git a/tests/src/test/resources/complete_input.json b/tests/src/test/resources/complete_input.json index 441d4497..36269da4 100644 --- a/tests/src/test/resources/complete_input.json +++ b/tests/src/test/resources/complete_input.json @@ -1,6 +1,7 @@ [ { "request": { + "exact-match": true, "protocol": "https", "method": "post", "host": "test.com", diff --git a/tests/src/test/resources/exact_match.json b/tests/src/test/resources/exact_match.json new file mode 100644 index 00000000..54e9019b --- /dev/null +++ b/tests/src/test/resources/exact_match.json @@ -0,0 +1,13 @@ +[ + { + "request": { + "exact-match" : true, + "params": { + "param1": "1" + } + }, + "response": { + "body": "Found response" + } + } +] From 2f65d641ce8011565c17241a5c56ff4e639fe8c3 Mon Sep 17 00:00:00 2001 From: speekha Date: Sun, 28 Jul 2019 23:01:40 +0200 Subject: [PATCH 11/32] Clean pattern matching for numeric and booleans --- .../httpmocker/custom/JsonStringReader.kt | 56 +++++++++++++------ 1 file changed, 39 insertions(+), 17 deletions(-) diff --git a/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonStringReader.kt b/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonStringReader.kt index f6955191..2ce67d6d 100644 --- a/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonStringReader.kt +++ b/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonStringReader.kt @@ -16,6 +16,7 @@ package fr.speekha.httpmocker.custom +import java.util.Locale import java.util.regex.Pattern /** @@ -30,6 +31,8 @@ class JsonStringReader( private val numericPattern = Pattern.compile("\\d[\\d ]*") + private val alphanumericPattern = Pattern.compile("[^,}\\]\\s]+") + /** * Checks whether the string still has tokens to process * @return false if the unit being parsed has been completely processed, true if it still @@ -114,13 +117,20 @@ class JsonStringReader( * Reads an Integer field value * @return the field value as an Integer */ - fun readInt(): Int = extractNumericLiteral().toInt() + fun readInt(): Int = parseNumeric(String::toInt) /** * Reads a Long field value * @return the field value as a Long */ - fun readLong(): Long = extractNumericLiteral().toLong() + fun readLong(): Long = parseNumeric(String::toLong) + + /** + * Reads a Boolean field value + * @return the field value as a Boolean + */ + fun readBoolean(): Boolean = + parseToken(alphanumericPattern, INVALID_BOOLEAN_ERROR, this::parseBoolean) /** * Reads a String field value @@ -149,15 +159,24 @@ class JsonStringReader( return adapter.fromJson(this) } - fun readBoolean(): Boolean { - val resultTrue = json.substring(index).trimStart().startsWith("true") - val resultFalse = json.substring(index).trimStart().startsWith("false") - if (!resultTrue && !resultFalse) { - parseError(INVALID_BOOLEAN_ERROR) + private fun parseNumeric(convert: String.() -> T): T = + parseToken(numericPattern, INVALID_NUMBER_ERROR) { + it.replace(" ", "").convert() } - index = if (resultTrue) json.indexOf("true", index) + 4 - else json.indexOf("false", index) + 5 - return resultTrue + + private fun parseToken(pattern: Pattern, error: String, converter: (String) -> T): T { + val position = index + return try { + converter(extractLiteral(pattern, error)) + } catch (e: Throwable) { + parseError(error, position) + } + } + + private fun parseBoolean(value: String): Boolean = when (value.toLowerCase(Locale.ROOT)) { + "true" -> true + "false" -> false + else -> error(INVALID_BOOLEAN_ERROR) } private fun extractStringLiteral(): String { @@ -171,23 +190,25 @@ class JsonStringReader( return json.substring(start + 1, end).replace("\\\"", "\"") } - private fun extractNumericLiteral(): String { - val matcher = numericPattern.matcher(json.substring(index)) + private fun extractAlphaNumericLiteral(): String = extractLiteral(alphanumericPattern) + + private fun extractLiteral(pattern: Pattern, error: String = INVALID_TOKEN_ERROR): String { + val matcher = pattern.matcher(json.substring(index)) if (!matcher.find() || !isBlank(index, index + matcher.start())) { - parseError(INVALID_NUMBER_ERROR) + parseError(error) } index += matcher.end() - return matcher.group().replace(" ", "") + return matcher.group() } private fun isFieldSeparator(start: Int, end: Int) = json.substring(start, end).trim() == ":" private fun isBlank(start: Int, end: Int) = json.substring(start, end).isBlank() - private fun parseError(message: String): Nothing = - error("$message${extractAfterCurrentPosition()}") + private fun parseError(message: String, position: Int = index): Nothing = + error("$message${extractAfterCurrentPosition(position)}") - private fun extractAfterCurrentPosition() = json.substring(index).truncate(10) + private fun extractAfterCurrentPosition(position: Int) = json.substring(position).truncate(10) } @@ -199,5 +220,6 @@ const val WRONG_END_OF_LIST_ERROR = "List is not entirely processed: " const val WRONG_START_OF_STRING_ERROR = "No string starts here: " const val WRONG_START_OF_STRING_FIELD_ERROR = "Not ready to read a string value for a field: " const val INVALID_NUMBER_ERROR = "Invalid numeric value: " +const val INVALID_TOKEN_ERROR = "Invalid token value: " const val INVALID_BOOLEAN_ERROR = "Invalid boolean value: " From 1e68f08b559cf97fb25ff9696acefb772dcf4a9a Mon Sep 17 00:00:00 2001 From: speekha Date: Wed, 31 Jul 2019 22:17:28 +0200 Subject: [PATCH 12/32] Clean pattern matching for numeric and booleans --- .../fr/speekha/httpmocker/custom/JsonStringReader.kt | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonStringReader.kt b/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonStringReader.kt index 2ce67d6d..af2a8fee 100644 --- a/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonStringReader.kt +++ b/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonStringReader.kt @@ -29,10 +29,6 @@ class JsonStringReader( private var index = 0 - private val numericPattern = Pattern.compile("\\d[\\d ]*") - - private val alphanumericPattern = Pattern.compile("[^,}\\]\\s]+") - /** * Checks whether the string still has tokens to process * @return false if the unit being parsed has been completely processed, true if it still @@ -190,8 +186,6 @@ class JsonStringReader( return json.substring(start + 1, end).replace("\\\"", "\"") } - private fun extractAlphaNumericLiteral(): String = extractLiteral(alphanumericPattern) - private fun extractLiteral(pattern: Pattern, error: String = INVALID_TOKEN_ERROR): String { val matcher = pattern.matcher(json.substring(index)) if (!matcher.find() || !isBlank(index, index + matcher.start())) { @@ -223,3 +217,5 @@ const val INVALID_NUMBER_ERROR = "Invalid numeric value: " const val INVALID_TOKEN_ERROR = "Invalid token value: " const val INVALID_BOOLEAN_ERROR = "Invalid boolean value: " +private val numericPattern = Pattern.compile("\\d[\\d ]*") +private val alphanumericPattern = Pattern.compile("[^,}\\]\\s]+") From cc3538de6a5b9a25fc5520194df9e2042c32b4e4 Mon Sep 17 00:00:00 2001 From: speekha Date: Wed, 31 Jul 2019 22:19:06 +0200 Subject: [PATCH 13/32] Deprecated InMemoryPolicy --- .../main/kotlin/fr/speekha/httpmocker/policies/InMemoryPolicy.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/mocker/src/main/kotlin/fr/speekha/httpmocker/policies/InMemoryPolicy.kt b/mocker/src/main/kotlin/fr/speekha/httpmocker/policies/InMemoryPolicy.kt index 2827cb87..0a3b3fca 100644 --- a/mocker/src/main/kotlin/fr/speekha/httpmocker/policies/InMemoryPolicy.kt +++ b/mocker/src/main/kotlin/fr/speekha/httpmocker/policies/InMemoryPolicy.kt @@ -31,6 +31,7 @@ import java.io.PipedOutputStream * val interceptor = MockResponseInterceptor(policy, policy::matchRequest) * } */ +@Deprecated("Dynamic mocks are a better way to mock calls programmatically") class InMemoryPolicy( private val mapper: Mapper ) : FilingPolicy { From ec991e55036613ac3d214e6fc6581f40a3f24d04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ste=CC=81phane=20Baiget?= Date: Thu, 1 Aug 2019 12:28:31 +0200 Subject: [PATCH 14/32] Move to Mockk --- demo/build.gradle | 3 +- .../httpmocker/demo/ui/MainViewModelTest.kt | 112 +++++++++--------- .../httpmocker/demo/ui/ViewModelTest.kt | 19 +++ 3 files changed, 75 insertions(+), 59 deletions(-) create mode 100644 demo/src/test/java/fr/speekha/httpmocker/demo/ui/ViewModelTest.kt diff --git a/demo/build.gradle b/demo/build.gradle index 38dbf1f0..429a7e14 100644 --- a/demo/build.gradle +++ b/demo/build.gradle @@ -78,8 +78,7 @@ dependencies { implementation project(':jackson-adapter') testImplementation "junit:junit:4.12" - testImplementation "org.mockito:mockito-core:$mockito_version" - testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:$mockito_kotlin_version" + testImplementation "io.mockk:mockk:1.9.3" testImplementation "androidx.arch.core:core-testing:2.0.1" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" } diff --git a/demo/src/test/java/fr/speekha/httpmocker/demo/ui/MainViewModelTest.kt b/demo/src/test/java/fr/speekha/httpmocker/demo/ui/MainViewModelTest.kt index d41d08f1..0e71dcf7 100644 --- a/demo/src/test/java/fr/speekha/httpmocker/demo/ui/MainViewModelTest.kt +++ b/demo/src/test/java/fr/speekha/httpmocker/demo/ui/MainViewModelTest.kt @@ -1,29 +1,21 @@ package fr.speekha.httpmocker.demo.ui -import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.Observer -import com.nhaarman.mockitokotlin2.mock -import com.nhaarman.mockitokotlin2.verify -import com.nhaarman.mockitokotlin2.whenever import fr.speekha.httpmocker.MockResponseInterceptor import fr.speekha.httpmocker.demo.R import fr.speekha.httpmocker.demo.model.Repo import fr.speekha.httpmocker.demo.model.User import fr.speekha.httpmocker.demo.service.GithubApiEndpoints +import fr.speekha.httpmocker.jackson.JacksonMapper +import io.mockk.* import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.runBlockingTest +import org.junit.Assert.assertEquals import org.junit.Before -import org.junit.Rule import org.junit.Test @ExperimentalCoroutinesApi -class MainViewModelTest { - - @get:Rule - var coroutinesTestRule = CoroutinesTestRule() - @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() +class MainViewModelTest : ViewModelTest() { private val org = "kotlin" private val repo = "repo" @@ -31,9 +23,11 @@ class MainViewModelTest { private val contributions = 1 private val id = 0L - private val mockService = mock() - private val mockResponseInterceptor = - MockResponseInterceptor.Builder().parseScenariosWith(mock()).build() + private val mockService = mockk() + private val mockResponseInterceptor = MockResponseInterceptor.Builder() + .parseScenariosWith(JacksonMapper()) + .build() + private lateinit var viewModel: MainViewModel @Before @@ -42,93 +36,97 @@ class MainViewModelTest { } @Test - fun `should load repos and top contributors successfully`() { - val observer = mock>() + fun `should succeed repos and top contributors calls`() = runBlockingTest { + val observer = spyk>() viewModel.getData().observeForever(observer) - coroutinesTestRule.testDispatcher.runBlockingTest { - whenever(mockService.listRepositoriesForOrganisation(org)) - .thenReturn(listOf(Repo(id, repo, topContributor = contributor))) - whenever(mockService.listContributorsForRepository(org, repo)) - .thenReturn(listOf(User(login = contributor, contributions = contributions))) - viewModel.callService() - } - - verify(observer).onChanged(Data.Loading) - verify(observer).onChanged( - Data.Success( - listOf( - Repo( - id, - repo, - topContributor = "$contributor - $contributions contributions" + coEvery { mockService.listRepositoriesForOrganisation(org) } returns + listOf(Repo(id, repo, topContributor = contributor)) + coEvery { mockService.listContributorsForRepository(org, repo) } returns + listOf(User(login = contributor, contributions = contributions)) + + viewModel.callService() + + coVerifyOrder { + observer.onChanged(Data.Loading) + observer.onChanged( + Data.Success( + listOf( + Repo( + id, + repo, + topContributor = "$contributor - $contributions contributions" + ) ) ) ) - ) + } + confirmVerified(observer) viewModel.getData().removeObserver(observer) } @Test - fun `should load repos successfully and fail top contributors`() { - val observer = mock>() + fun `should succeed repos call and fail top contributors call`() = runBlockingTest { + val observer = spyk>() viewModel.getData().observeForever(observer) + coEvery { mockService.listRepositoriesForOrganisation(org) } returns + listOf(Repo(id, repo, topContributor = contributor)) + coEvery { mockService.listContributorsForRepository(org, repo) } returns emptyList() - coroutinesTestRule.testDispatcher.runBlockingTest { - whenever(mockService.listRepositoriesForOrganisation(org)) - .thenReturn(listOf(Repo(id, repo, topContributor = contributor))) - whenever(mockService.listContributorsForRepository(org, repo)) - .thenReturn(null) - viewModel.callService() - } + viewModel.callService() - verify(observer).onChanged(Data.Loading) - verify(observer).onChanged( - Data.Success( - listOf(Repo(id, repo)) + coVerifyOrder { + observer.onChanged(Data.Loading) + observer.onChanged( + Data.Success(listOf(Repo(id, repo))) ) - ) + } + confirmVerified(observer) viewModel.getData().removeObserver(observer) } @Test fun `should update state according to disabled mode`() { - val observer = mock>() + val observer = spyk>() viewModel.getState().observeForever(observer) viewModel.setMode(MockResponseInterceptor.Mode.DISABLED) - verify(observer).onChanged(State.Message(R.string.disabled_description)) + assertEquals(mockResponseInterceptor.mode, MockResponseInterceptor.Mode.DISABLED) + verify { observer.onChanged(State.Message(R.string.disabled_description)) } } @Test fun `should update state according to enabled mode`() { - val observer = mock>() + val observer = spyk>() viewModel.getState().observeForever(observer) viewModel.setMode(MockResponseInterceptor.Mode.ENABLED) - verify(observer).onChanged(State.Message(R.string.enabled_description)) + assertEquals(mockResponseInterceptor.mode, MockResponseInterceptor.Mode.ENABLED) + verify { observer.onChanged(State.Message(R.string.enabled_description)) } } @Test fun `should update state according to mixed mode`() { - val observer = mock>() + val observer = spyk>() viewModel.getState().observeForever(observer) viewModel.setMode(MockResponseInterceptor.Mode.MIXED) - verify(observer).onChanged(State.Message(R.string.mixed_description)) + assertEquals(mockResponseInterceptor.mode, MockResponseInterceptor.Mode.MIXED) + verify { observer.onChanged(State.Message(R.string.mixed_description)) } } @Test fun `should check permission and update state according to record mode`() { - val observer = mock>() + val observer = spyk>() viewModel.getState().observeForever(observer) viewModel.setMode(MockResponseInterceptor.Mode.RECORD) - verify(observer).onChanged(State.Permission) - verify(observer).onChanged(State.Message(R.string.record_description)) + assertEquals(mockResponseInterceptor.mode, MockResponseInterceptor.Mode.RECORD) + verify { observer.onChanged(State.Permission) } + verify { observer.onChanged(State.Message(R.string.record_description)) } } } diff --git a/demo/src/test/java/fr/speekha/httpmocker/demo/ui/ViewModelTest.kt b/demo/src/test/java/fr/speekha/httpmocker/demo/ui/ViewModelTest.kt new file mode 100644 index 00000000..1a1971dc --- /dev/null +++ b/demo/src/test/java/fr/speekha/httpmocker/demo/ui/ViewModelTest.kt @@ -0,0 +1,19 @@ +package fr.speekha.httpmocker.demo.ui + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineScope +import kotlinx.coroutines.test.runBlockingTest +import org.junit.Rule + +@ExperimentalCoroutinesApi +open class ViewModelTest { + + @get:Rule + var coroutinesTestRule = CoroutinesTestRule() + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + fun runBlockingTest(block: suspend TestCoroutineScope.() -> Unit) = + coroutinesTestRule.testDispatcher.runBlockingTest(block) +} \ No newline at end of file From b561114cc6246ab0a5663983fb7fa7f4e2d41586 Mon Sep 17 00:00:00 2001 From: David Blanc Date: Wed, 17 Jul 2019 20:39:33 +0200 Subject: [PATCH 15/32] Update issue templates --- .github/ISSUE_TEMPLATE/bug_report.md | 38 +++++++++++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 20 ++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..dd84ea78 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..bbcbbe7d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. From c44bab9032d757c871b1e8d93934498d49cd3d20 Mon Sep 17 00:00:00 2001 From: David Blanc Date: Wed, 17 Jul 2019 20:39:33 +0200 Subject: [PATCH 16/32] Bump version --- README.md | 14 +++++++------- gradle/versions.gradle | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 7f4ca532..243f813a 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ configuration files instead. The interceptor will also allow to record scenarios ## Current Version ```gradle -httpmocker_version = '1.1.5' +httpmocker_version = '1.1.6' ``` ## Gradle @@ -55,19 +55,19 @@ you need to add is the corresponding ```gradle // Parses JSON scenarios using Jackson -implementation "fr.speekha.httpmocker:jackson-adapter:1.1.5" +implementation "fr.speekha.httpmocker:jackson-adapter:1.1.6" // Parses JSON scenarios using Gson -implementation "fr.speekha.httpmocker:gson-adapter:1.1.5" +implementation "fr.speekha.httpmocker:gson-adapter:1.1.6" // Parses JSON scenarios using Moshi -implementation "fr.speekha.httpmocker:moshi-adapter:1.1.5" +implementation "fr.speekha.httpmocker:moshi-adapter:1.1.6" // Parses JSON scenarios using Kotlinx Serialization -implementation "fr.speekha.httpmocker:kotlinx-adapter:1.1.5" +implementation "fr.speekha.httpmocker:kotlinx-adapter:1.1.6" // Parses JSON scenarios using a custom JSON parser -implementation "fr.speekha.httpmocker:custom-adapter:1.1.5" +implementation "fr.speekha.httpmocker:custom-adapter:1.1.6" ``` If none of those options suits your needs or if you would prefer to only use dynamic mocks, you can add @@ -75,7 +75,7 @@ the main dependency to your project (using static mocks will require that you pr of the `Mapper` class): ```gradle -implementation "fr.speekha.httpmocker:mocker:1.1.5" +implementation "fr.speekha.httpmocker:mocker:1.1.6" ``` #### External dependencies diff --git a/gradle/versions.gradle b/gradle/versions.gradle index 4365089e..1645ebd5 100644 --- a/gradle/versions.gradle +++ b/gradle/versions.gradle @@ -15,7 +15,7 @@ */ ext { - httpmock_version = '1.1.5' + httpmock_version = '1.1.6' kotlin_version = '1.3.41' coroutines_version = '1.2.1' From 35ac1907c4490e73e684788bbae9929f5acacc53 Mon Sep 17 00:00:00 2001 From: David Blanc Date: Wed, 17 Jul 2019 22:03:48 +0200 Subject: [PATCH 17/32] Update circle-ci image --- .circleci/config.yml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index f2b16f88..c528d599 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -30,7 +30,7 @@ commands: jobs: prepare_dependencies: docker: - - image: circleci/android:api-28 + - image: circleci/android:api-29 environment: JVM_OPTS: -Xmx3200m #TERM: dumb @@ -66,7 +66,7 @@ jobs: - "." build_core: docker: - - image: circleci/android:api-28 + - image: circleci/android:api-29 environment: JVM_OPTS: -Xmx3200m steps: @@ -74,7 +74,7 @@ jobs: module: "mocker" build_jackson: docker: - - image: circleci/android:api-28 + - image: circleci/android:api-29 environment: JVM_OPTS: -Xmx3200m steps: @@ -82,7 +82,7 @@ jobs: module: "jackson-adapter" build_gson: docker: - - image: circleci/android:api-28 + - image: circleci/android:api-29 environment: JVM_OPTS: -Xmx3200m steps: @@ -90,7 +90,7 @@ jobs: module: "gson-adapter" build_moshi: docker: - - image: circleci/android:api-28 + - image: circleci/android:api-29 environment: JVM_OPTS: -Xmx3200m steps: @@ -98,7 +98,7 @@ jobs: module: "moshi-adapter" build_custom: docker: - - image: circleci/android:api-28 + - image: circleci/android:api-29 environment: JVM_OPTS: -Xmx3200m steps: @@ -106,7 +106,7 @@ jobs: module: "custom-adapter" build_kotlinx: docker: - - image: circleci/android:api-28 + - image: circleci/android:api-29 environment: JVM_OPTS: -Xmx3200m steps: @@ -114,7 +114,7 @@ jobs: module: "kotlinx-adapter" build_demo: docker: - - image: circleci/android:api-28 + - image: circleci/android:api-29 environment: JVM_OPTS: -Xmx3200m steps: @@ -143,7 +143,7 @@ jobs: - demo/build store_artifacts: docker: - - image: circleci/android:api-28 + - image: circleci/android:api-29 environment: JVM_OPTS: -Xmx3200m steps: @@ -187,7 +187,7 @@ jobs: destination: apks test: docker: - - image: circleci/android:api-28 + - image: circleci/android:api-29 environment: JVM_OPTS: -Xmx3200m steps: @@ -231,7 +231,7 @@ jobs: - "." publish_snapshot: docker: - - image: circleci/android:api-28 + - image: circleci/android:api-29 steps: - restore_cache: key: build-{{ .Branch }}-{{ .Revision }} @@ -242,7 +242,7 @@ jobs: command: ./publishSnapshot.sh ${BINTRAY_USER} ${BINTRAY_APIKEY} publish_release: docker: - - image: circleci/android:api-28 + - image: circleci/android:api-29 steps: - restore_cache: key: build-{{ .Branch }}-{{ .Revision }} From 3bc5b33257e86b70a5ffb9827055b30011c32848 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ste=CC=81phane=20Baiget?= Date: Fri, 19 Jul 2019 09:43:21 +0200 Subject: [PATCH 18/32] Correct warnings on style. --- demo/src/main/res/layout/activity_main.xml | 169 +++++++++++---------- 1 file changed, 90 insertions(+), 79 deletions(-) diff --git a/demo/src/main/res/layout/activity_main.xml b/demo/src/main/res/layout/activity_main.xml index dca782b0..47f40764 100644 --- a/demo/src/main/res/layout/activity_main.xml +++ b/demo/src/main/res/layout/activity_main.xml @@ -14,96 +14,107 @@ ~ limitations under the License. --> - + + android:id="@+id/tvState" + style="?textAppearanceSubtitle1" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="16dp" + android:layout_marginTop="16dp" + android:text="@string/mocking_state" + app:layout_constraintLeft_toLeftOf="parent" + app:layout_constraintTop_toTopOf="parent" + /> - + - + - + - + - + android:id="@+id/stateDisabled" + style="@style/Widget.MaterialComponents.Button.OutlinedButton" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/state_disabled" + /> - + android:text="@string/state_enabled" + /> - + android:text="@string/state_mixed" + /> - + + \ No newline at end of file From 1a5ac3ac1b63663994a1b51ab7d5124a3db6338a Mon Sep 17 00:00:00 2001 From: David Blanc Date: Sat, 20 Jul 2019 21:52:35 +0200 Subject: [PATCH 19/32] Handle quotes in strings --- .../httpmocker/custom/JsonSerialization.kt | 8 +++--- .../httpmocker/custom/JsonStringReader.kt | 5 ++-- .../fr/speekha/httpmocker/gson/GsonMapper.kt | 1 + .../mappers/AbstractJsonMapperTest.kt | 26 ++++++++++++++++++- .../mappers/JsonStringReaderTest.kt | 10 ++++++- .../fr/speekha/httpmocker/mappers/TestData.kt | 3 ++- tests/src/test/resources/complete_input.json | 3 ++- 7 files changed, 46 insertions(+), 10 deletions(-) diff --git a/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonSerialization.kt b/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonSerialization.kt index 93fcf660..1f160db3 100644 --- a/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonSerialization.kt +++ b/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonSerialization.kt @@ -55,14 +55,14 @@ internal fun RequestDescriptor.toJson(): String = listOf( postfix = "\n }" ) { (key, value) -> " \"$key\": $value" } -internal fun Map.toJson(): String = +internal fun Map.toJson(): String = entries.joinToString( separator = ",\n", prefix = "{\n", postfix = "\n }" - ) { " \"${it.key}\": \"${it.value}\"" } + ) { " \"${it.key}\": ${it.value.wrap()}" } -internal fun Header.toJson(): String = "\"$name\": \"$value\"" +internal fun Header.toJson(): String = "\"$name\": ${value.wrap()}" internal fun ResponseDescriptor.toJson(): String = listOf( "delay" to delay.toString(), @@ -79,7 +79,7 @@ internal fun ResponseDescriptor.toJson(): String = listOf( postfix = "\n }" ) { (key, value) -> " \"$key\": $value" } -private fun String?.wrap() = this?.let { "\"$it\"" } +private fun String?.wrap() = this?.let { "\"${it.replace("\"", "\\\"")}\"" } private fun Int?.wrap() = this?.toString() diff --git a/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonStringReader.kt b/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonStringReader.kt index 331a491e..e3298c2a 100644 --- a/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonStringReader.kt +++ b/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonStringReader.kt @@ -151,12 +151,13 @@ class JsonStringReader( private fun extractStringLiteral(): String { val start = json.indexOf("\"", index) - val end = json.indexOf("\"", start + 1) + val match = Regex("[^\\\\]\"").find(json, start) + val end = match?.range?.endInclusive ?: -1 if (start < 1 || end == -1 || !isBlank(index, start)) { parseError(WRONG_START_OF_STRING_ERROR) } index = end + 1 - return json.substring(start + 1, end) + return json.substring(start + 1, end).replace("\\\"", "\"") } private fun extractNumericLiteral(): String { diff --git a/gson-adapter/src/main/kotlin/fr/speekha/httpmocker/gson/GsonMapper.kt b/gson-adapter/src/main/kotlin/fr/speekha/httpmocker/gson/GsonMapper.kt index df0bade6..f3fd4000 100644 --- a/gson-adapter/src/main/kotlin/fr/speekha/httpmocker/gson/GsonMapper.kt +++ b/gson-adapter/src/main/kotlin/fr/speekha/httpmocker/gson/GsonMapper.kt @@ -37,6 +37,7 @@ class GsonMapper : Mapper { private val adapter: Gson = GsonBuilder() .setPrettyPrinting() + .disableHtmlEscaping() .registerTypeAdapter(HeaderAdapter.HeaderList::class.java, HeaderAdapter()) .create() diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/AbstractJsonMapperTest.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/AbstractJsonMapperTest.kt index bee0803c..0243350e 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/AbstractJsonMapperTest.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/AbstractJsonMapperTest.kt @@ -37,7 +37,6 @@ abstract class AbstractJsonMapperTest(val mapper: Mapper) { assertEquals(partialData, result) } - @Test fun `should handle headers with colons`() { val json = """[ @@ -63,6 +62,31 @@ abstract class AbstractJsonMapperTest(val mapper: Mapper) { ) } + @Test + fun `should handle headers with quotes`() { + val json = """[ + { + "response": { + "headers": { + "Set-Cookie": "\"cookie\"=\"value\"" + } + } + } +]""" + + assertEquals( + listOf( + Matcher( + response = ResponseDescriptor( + headers = listOf( + Header("Set-Cookie", "\"cookie\"=\"value\"") + ) + ) + ) + ), mapper.readMatches(json.byteInputStream()) + ) + } + @Test fun `should write a proper JSON file`() { val expected = getExpectedOutput() diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JsonStringReaderTest.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JsonStringReaderTest.kt index d31794c5..68e069a3 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JsonStringReaderTest.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JsonStringReaderTest.kt @@ -35,7 +35,6 @@ import java.util.stream.Stream class JsonStringReaderTest { - @Test fun `should parse empty string`() { val reader = JsonStringReader("") @@ -107,6 +106,15 @@ class JsonStringReaderTest { assertEquals(result, reader.readString()) } + @Test + fun `should read string with quotes`() { + val result = """a test \"string\"""" + val reader = JsonStringReader("{\"field\":\"$result\"}") + reader.beginObject() + reader.readFieldName() + assertEquals("""a test "string"""", reader.readString()) + } + @ParameterizedTest @MethodSource("stringErrors") fun `should detect error on read string`(input: String, output: String) { diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/TestData.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/TestData.kt index ebec1fca..ed09b2dd 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/TestData.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/TestData.kt @@ -35,7 +35,8 @@ internal val completeData = listOf( headers = listOf( Header("reqHeader1", "1"), Header("reqHeader1", "2"), - Header("reqHeader2", "3") + Header("reqHeader2", "3"), + Header("Set-Cookie", "\"cookie\"=\"value\"") ), params = mapOf("param1" to "1", "param2" to "2"), body = ".*1.*" diff --git a/tests/src/test/resources/complete_input.json b/tests/src/test/resources/complete_input.json index 421286eb..441d4497 100644 --- a/tests/src/test/resources/complete_input.json +++ b/tests/src/test/resources/complete_input.json @@ -9,7 +9,8 @@ "headers": { "reqHeader1": "1", "reqHeader1": "2", - "reqHeader2": "3" + "reqHeader2": "3", + "Set-Cookie": "\"cookie\"=\"value\"" }, "params": { "param1": "1", From 23742d5926ce8786762ed484bfb84c82ecd92cc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ste=CC=81phane=20Baiget?= Date: Fri, 19 Jul 2019 08:09:54 +0200 Subject: [PATCH 20/32] Move to Android JetPack ViewModel. --- demo/build.gradle | 20 ++- .../httpmocker/demo/di/InjectionModule.kt | 10 +- .../httpmocker/demo/ui/MainActivity.kt | 52 +++++-- .../httpmocker/demo/ui/MainContract.kt | 37 ----- .../httpmocker/demo/ui/MainPresenter.kt | 84 ----------- .../speekha/httpmocker/demo/ui/MainScope.kt | 30 ---- .../httpmocker/demo/ui/MainViewModel.kt | 86 +++++++++++ demo/src/main/res/layout/activity_main.xml | 27 +++- .../httpmocker/demo/ui/CoroutinesTestRule.kt | 26 ++++ .../httpmocker/demo/ui/MainViewModelTest.kt | 134 ++++++++++++++++++ .../org.mockito.plugins.MockMaker | 1 + gradle/versions.gradle | 2 + tests/build.gradle | 4 +- 13 files changed, 329 insertions(+), 184 deletions(-) delete mode 100644 demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainContract.kt delete mode 100644 demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainPresenter.kt delete mode 100644 demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainScope.kt create mode 100644 demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainViewModel.kt create mode 100644 demo/src/test/java/fr/speekha/httpmocker/demo/ui/CoroutinesTestRule.kt create mode 100644 demo/src/test/java/fr/speekha/httpmocker/demo/ui/MainViewModelTest.kt create mode 100644 demo/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker diff --git a/demo/build.gradle b/demo/build.gradle index 3dfa103d..38dbf1f0 100644 --- a/demo/build.gradle +++ b/demo/build.gradle @@ -15,9 +15,7 @@ */ apply plugin: 'com.android.application' - apply plugin: 'kotlin-android' - apply plugin: 'kotlin-android-extensions' @@ -47,7 +45,10 @@ android { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } - buildToolsVersion '28.0.3' + testOptions { + unitTests.returnDefaultValues = true + } + buildToolsVersion '29.0.1' } //repositories { @@ -56,12 +57,13 @@ android { //} dependencies { - implementation fileTree(include: ['*.jar'], dir: 'libs') - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" implementation "androidx.appcompat:appcompat:$support_version" + implementation "androidx.core:core-ktx:1.0.2" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.1.0-rc01" implementation 'androidx.constraintlayout:constraintlayout:1.1.3' implementation 'androidx.recyclerview:recyclerview:1.0.0' implementation 'com.google.android.material:material:1.1.0-alpha08' @@ -69,9 +71,15 @@ dependencies { implementation "com.squareup.retrofit2:retrofit:$retrofit_version" implementation "com.squareup.retrofit2:converter-jackson:$retrofit_version" - implementation "org.koin:koin-android:2.0.1" + implementation "org.koin:koin-androidx-viewmodel:2.0.1" implementation "org.slf4j:slf4j-android:$slf4j_version" //implementation "fr.speekha.httpmocker:jackson-adapter:$httpmock_version" implementation project(':jackson-adapter') + + testImplementation "junit:junit:4.12" + testImplementation "org.mockito:mockito-core:$mockito_version" + testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:$mockito_kotlin_version" + testImplementation "androidx.arch.core:core-testing:2.0.1" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" } diff --git a/demo/src/main/java/fr/speekha/httpmocker/demo/di/InjectionModule.kt b/demo/src/main/java/fr/speekha/httpmocker/demo/di/InjectionModule.kt index fdf977b8..d22116dd 100644 --- a/demo/src/main/java/fr/speekha/httpmocker/demo/di/InjectionModule.kt +++ b/demo/src/main/java/fr/speekha/httpmocker/demo/di/InjectionModule.kt @@ -20,11 +20,11 @@ import android.content.Context import android.os.Environment import fr.speekha.httpmocker.MockResponseInterceptor import fr.speekha.httpmocker.demo.service.GithubApiEndpoints -import fr.speekha.httpmocker.demo.ui.MainContract -import fr.speekha.httpmocker.demo.ui.MainPresenter +import fr.speekha.httpmocker.demo.ui.MainViewModel import fr.speekha.httpmocker.jackson.JacksonMapper import fr.speekha.httpmocker.policies.MirrorPathPolicy import okhttp3.OkHttpClient +import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.core.module.Module import org.koin.dsl.module import retrofit2.Retrofit @@ -62,5 +62,7 @@ val injectionModule: Module = module { get().create(GithubApiEndpoints::class.java) } - factory { (view: MainContract.View) -> MainPresenter(view, get(), get()) } -} \ No newline at end of file + viewModel { + MainViewModel(get(), get()) + } +} diff --git a/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainActivity.kt b/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainActivity.kt index 18bcd9cd..647f491c 100644 --- a/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainActivity.kt +++ b/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainActivity.kt @@ -20,30 +20,48 @@ import android.Manifest.permission.WRITE_EXTERNAL_STORAGE import android.content.pm.PackageManager import android.os.Build import android.os.Bundle -import androidx.annotation.IntegerRes +import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat +import androidx.core.view.isVisible +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer import androidx.recyclerview.widget.LinearLayoutManager import fr.speekha.httpmocker.MockResponseInterceptor import fr.speekha.httpmocker.demo.R import fr.speekha.httpmocker.demo.model.Repo import kotlinx.android.synthetic.main.activity_main.* -import org.koin.android.ext.android.inject -import org.koin.core.parameter.parametersOf +import org.koin.androidx.viewmodel.ext.android.viewModel -class MainActivity : AppCompatActivity(), MainContract.View { - private val presenter: MainContract.Presenter by inject { parametersOf(this) } +class MainActivity : AppCompatActivity() { + private val viewModel by viewModel() private val adapter = RepoAdapter(this) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) + observe(viewModel.getData()) { data -> + when (data) { + is Data.Loading -> showLoading(true) + is Data.Success -> setResult(data.repos) + is Data.Error -> setError(data.message) + } + } + + observe(viewModel.getState()) { state -> + when (state) { + is State.Permission -> checkPermission() + is State.Message -> updateDescriptionLabel(state.message) + } + } + radioState.addOnButtonCheckedListener { _, checkedId, isChecked -> if (isChecked) { - presenter.setMode( + viewModel.setMode( when (checkedId) { R.id.stateEnabled -> MockResponseInterceptor.Mode.ENABLED R.id.stateMixed -> MockResponseInterceptor.Mode.MIXED @@ -57,36 +75,42 @@ class MainActivity : AppCompatActivity(), MainContract.View { } btnCall.setOnClickListener { - presenter.callService() + viewModel.callService() } results.adapter = adapter results.layoutManager = LinearLayoutManager(this) } - override fun onStop() { - super.onStop() - presenter.stop() + private fun showLoading(visible: Boolean) { + results.isVisible = !visible + loader.isVisible = visible } - override fun setResult(result: List) { + private fun setResult(result: List) { + showLoading(false) adapter.repos = result adapter.notifyDataSetChanged() } - override fun setError(message: String?) { + private fun setError(message: String?) { + showLoading(false) adapter.repos = null adapter.errorMessage = message adapter.notifyDataSetChanged() } - override fun checkPermission() { + private fun checkPermission() { if (Build.VERSION.SDK_INT >= 23 && checkSelfPermission(WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, arrayOf(WRITE_EXTERNAL_STORAGE), 1) } } - override fun updateDescriptionLabel(@IntegerRes resId: Int) { + private fun updateDescriptionLabel(@StringRes resId: Int) { tvMessage.setText(resId) } } + +fun > LifecycleOwner.observe(liveData: L, body: (T?) -> Unit) { + liveData.observe(this, Observer(body)) +} diff --git a/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainContract.kt b/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainContract.kt deleted file mode 100644 index 999e7f72..00000000 --- a/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainContract.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright 2019 David Blanc - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.speekha.httpmocker.demo.ui - -import androidx.annotation.IntegerRes -import fr.speekha.httpmocker.MockResponseInterceptor -import fr.speekha.httpmocker.demo.model.Repo - -interface MainContract { - - interface View { - fun setResult(result: List) - fun setError(message: String?) - fun checkPermission() - fun updateDescriptionLabel(@IntegerRes resId: Int) - } - - interface Presenter { - fun stop() - fun callService() - fun setMode(mode: MockResponseInterceptor.Mode) - } -} \ No newline at end of file diff --git a/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainPresenter.kt b/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainPresenter.kt deleted file mode 100644 index 63391477..00000000 --- a/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainPresenter.kt +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright 2019 David Blanc - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.speekha.httpmocker.demo.ui - -import android.util.Log -import fr.speekha.httpmocker.MockResponseInterceptor -import fr.speekha.httpmocker.demo.R -import fr.speekha.httpmocker.demo.service.GithubApiEndpoints -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - - -class MainPresenter( - private val view: MainContract.View, - private val apiService: GithubApiEndpoints, - private val mocker: MockResponseInterceptor -) : MainContract.Presenter, CoroutineScope by MainScope() { - - override fun callService() { - launch { - try { - val org = "kotlin" - val repos = loadRepos(org) - .map { - val contributor = loadTopContributor(org, it.name)?.firstOrNull() - it.copy(topContributor = contributor?.run { "$login - $contributions contributions" }) - } - view.setResult(repos) - } catch (e: Throwable) { - view.setError(e.message) - } - } - } - - private suspend fun loadRepos(org: String) = withContext(Dispatchers.IO) { - apiService.listRepositoriesForOrganisation(org) - } - - private suspend fun loadTopContributor(org: String, repo: String) = - withContext(Dispatchers.IO) { - try { - apiService.listContributorsForRepository(org, repo) - } catch (e: Throwable) { - Log.e("Presenter", e.message, e) - null - } - } - - override fun setMode(mode: MockResponseInterceptor.Mode) { - mocker.mode = mode - if (mocker.mode == MockResponseInterceptor.Mode.RECORD) { - view.checkPermission() - } - view.updateDescriptionLabel( - when (mocker.mode) { - MockResponseInterceptor.Mode.DISABLED -> R.string.disabled_description - MockResponseInterceptor.Mode.ENABLED -> R.string.enabled_description - MockResponseInterceptor.Mode.MIXED -> R.string.mixed_description - MockResponseInterceptor.Mode.RECORD -> R.string.record_description - } - ) - } - - override fun stop() { - coroutineContext.cancel() - } -} \ No newline at end of file diff --git a/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainScope.kt b/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainScope.kt deleted file mode 100644 index f3537577..00000000 --- a/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainScope.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright 2019 David Blanc - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.speekha.httpmocker.demo.ui - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlin.coroutines.CoroutineContext - -class MainScope : CoroutineScope { - - val job = Job() - - override val coroutineContext: CoroutineContext = Dispatchers.Main + job - -} diff --git a/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainViewModel.kt b/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainViewModel.kt new file mode 100644 index 00000000..db54c533 --- /dev/null +++ b/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainViewModel.kt @@ -0,0 +1,86 @@ +package fr.speekha.httpmocker.demo.ui + +import android.util.Log +import androidx.annotation.StringRes +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import fr.speekha.httpmocker.MockResponseInterceptor +import fr.speekha.httpmocker.demo.R +import fr.speekha.httpmocker.demo.model.Repo +import fr.speekha.httpmocker.demo.service.GithubApiEndpoints +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class MainViewModel( + private val apiService: GithubApiEndpoints, + private val mocker: MockResponseInterceptor +) : ViewModel() { + + private val data = MutableLiveData() + private val state = MutableLiveData() + private val exceptionHandler = CoroutineExceptionHandler { _, exception -> + data.postValue(Data.Error(exception.message)) + } + + fun getData(): LiveData = data + fun getState(): LiveData = state + + fun callService() { + viewModelScope.launch(exceptionHandler) { + data.postValue(Data.Loading) + val org = "kotlin" + val repos = loadRepos(org) + .map { + val contributor = loadTopContributor(org, it.name)?.firstOrNull() + it.copy(topContributor = contributor?.run { "$login - $contributions contributions" }) + } + data.postValue(Data.Success(repos)) + } + } + + fun setMode(mode: MockResponseInterceptor.Mode) { + mocker.mode = mode + if (mocker.mode == MockResponseInterceptor.Mode.RECORD) { + state.postValue(State.Permission) + } + state.postValue( + State.Message( + when (mocker.mode) { + MockResponseInterceptor.Mode.DISABLED -> R.string.disabled_description + MockResponseInterceptor.Mode.ENABLED -> R.string.enabled_description + MockResponseInterceptor.Mode.MIXED -> R.string.mixed_description + MockResponseInterceptor.Mode.RECORD -> R.string.record_description + } + ) + ) + } + + private suspend fun loadRepos(org: String) = withContext(Dispatchers.IO) { + apiService.listRepositoriesForOrganisation(org) + } + + private suspend fun loadTopContributor(org: String, repo: String) = + withContext(Dispatchers.IO) { + try { + apiService.listContributorsForRepository(org, repo) + } catch (e: Throwable) { + Log.e("ViewModel", e.message, e) + null + } + } +} + +sealed class Data { + object Loading : Data() + data class Success(val repos: List) : Data() + data class Error(val message: String?) : Data() +} + +sealed class State { + data class Message(@StringRes val message: Int) : State() + object Permission : State() +} diff --git a/demo/src/main/res/layout/activity_main.xml b/demo/src/main/res/layout/activity_main.xml index 47f40764..149a115d 100644 --- a/demo/src/main/res/layout/activity_main.xml +++ b/demo/src/main/res/layout/activity_main.xml @@ -23,16 +23,17 @@ tools:context=".ui.MainActivity" > - + + () + private val mockResponseInterceptor = + MockResponseInterceptor.Builder().parseScenariosWith(mock()).build() + private lateinit var viewModel: MainViewModel + + @Before + fun setup() { + viewModel = MainViewModel(mockService, mockResponseInterceptor) + } + + @Test + fun `should load repos and top contributors successfully`() { + val observer = mock>() + viewModel.getData().observeForever(observer) + + coroutinesTestRule.testDispatcher.runBlockingTest { + whenever(mockService.listRepositoriesForOrganisation(org)) + .thenReturn(listOf(Repo(id, repo, topContributor = contributor))) + whenever(mockService.listContributorsForRepository(org, repo)) + .thenReturn(listOf(User(login = contributor, contributions = contributions))) + viewModel.callService() + } + + verify(observer).onChanged(Data.Loading) + verify(observer).onChanged( + Data.Success( + listOf( + Repo( + id, + repo, + topContributor = "$contributor - $contributions contributions" + ) + ) + ) + ) + viewModel.getData().removeObserver(observer) + } + + @Test + fun `should load repos successfully and fail top contributors`() { + val observer = mock>() + viewModel.getData().observeForever(observer) + + coroutinesTestRule.testDispatcher.runBlockingTest { + whenever(mockService.listRepositoriesForOrganisation(org)) + .thenReturn(listOf(Repo(id, repo, topContributor = contributor))) + whenever(mockService.listContributorsForRepository(org, repo)) + .thenReturn(null) + viewModel.callService() + } + + verify(observer).onChanged(Data.Loading) + verify(observer).onChanged( + Data.Success( + listOf(Repo(id, repo)) + ) + ) + viewModel.getData().removeObserver(observer) + } + + @Test + fun `should update state according to disabled mode`() { + val observer = mock>() + viewModel.getState().observeForever(observer) + + viewModel.setMode(MockResponseInterceptor.Mode.DISABLED) + + verify(observer).onChanged(State.Message(R.string.disabled_description)) + } + + @Test + fun `should update state according to enabled mode`() { + val observer = mock>() + viewModel.getState().observeForever(observer) + + viewModel.setMode(MockResponseInterceptor.Mode.ENABLED) + + verify(observer).onChanged(State.Message(R.string.enabled_description)) + } + + @Test + fun `should update state according to mixed mode`() { + val observer = mock>() + viewModel.getState().observeForever(observer) + + viewModel.setMode(MockResponseInterceptor.Mode.MIXED) + + verify(observer).onChanged(State.Message(R.string.mixed_description)) + } + + @Test + fun `should check permission and update state according to record mode`() { + val observer = mock>() + viewModel.getState().observeForever(observer) + + viewModel.setMode(MockResponseInterceptor.Mode.RECORD) + + verify(observer).onChanged(State.Permission) + verify(observer).onChanged(State.Message(R.string.record_description)) + } +} diff --git a/demo/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/demo/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 00000000..ca6ee9ce --- /dev/null +++ b/demo/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline \ No newline at end of file diff --git a/gradle/versions.gradle b/gradle/versions.gradle index 1645ebd5..04590341 100644 --- a/gradle/versions.gradle +++ b/gradle/versions.gradle @@ -30,4 +30,6 @@ ext { artifactory_version = '4.9.6' slf4j_version = '1.7.26' junit_version = '5.4.2' + mockito_version = '2.27.0' + mockito_kotlin_version = '2.1.0' } \ No newline at end of file diff --git a/tests/build.gradle b/tests/build.gradle index 012fbc46..f010864e 100644 --- a/tests/build.gradle +++ b/tests/build.gradle @@ -52,8 +52,8 @@ dependencies { testImplementation "org.junit.jupiter:junit-jupiter-api:$junit_version" testImplementation "org.junit.jupiter:junit-jupiter-params:$junit_version" testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junit_version" - testImplementation "org.mockito:mockito-core:2.27.0" - testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.1.0" + testImplementation "org.mockito:mockito-core:$mockito_version" + testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:$mockito_kotlin_version" testImplementation "org.slf4j:slf4j-simple:$slf4j_version" From bfd4c1179b3b0a5f1db9670b59f084617000519e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ste=CC=81phane=20Baiget?= Date: Thu, 1 Aug 2019 12:28:31 +0200 Subject: [PATCH 21/32] Move to Mockk --- demo/build.gradle | 3 +- .../httpmocker/demo/ui/MainViewModelTest.kt | 112 +++++++++--------- .../httpmocker/demo/ui/ViewModelTest.kt | 19 +++ 3 files changed, 75 insertions(+), 59 deletions(-) create mode 100644 demo/src/test/java/fr/speekha/httpmocker/demo/ui/ViewModelTest.kt diff --git a/demo/build.gradle b/demo/build.gradle index 38dbf1f0..429a7e14 100644 --- a/demo/build.gradle +++ b/demo/build.gradle @@ -78,8 +78,7 @@ dependencies { implementation project(':jackson-adapter') testImplementation "junit:junit:4.12" - testImplementation "org.mockito:mockito-core:$mockito_version" - testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:$mockito_kotlin_version" + testImplementation "io.mockk:mockk:1.9.3" testImplementation "androidx.arch.core:core-testing:2.0.1" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" } diff --git a/demo/src/test/java/fr/speekha/httpmocker/demo/ui/MainViewModelTest.kt b/demo/src/test/java/fr/speekha/httpmocker/demo/ui/MainViewModelTest.kt index d41d08f1..0e71dcf7 100644 --- a/demo/src/test/java/fr/speekha/httpmocker/demo/ui/MainViewModelTest.kt +++ b/demo/src/test/java/fr/speekha/httpmocker/demo/ui/MainViewModelTest.kt @@ -1,29 +1,21 @@ package fr.speekha.httpmocker.demo.ui -import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.Observer -import com.nhaarman.mockitokotlin2.mock -import com.nhaarman.mockitokotlin2.verify -import com.nhaarman.mockitokotlin2.whenever import fr.speekha.httpmocker.MockResponseInterceptor import fr.speekha.httpmocker.demo.R import fr.speekha.httpmocker.demo.model.Repo import fr.speekha.httpmocker.demo.model.User import fr.speekha.httpmocker.demo.service.GithubApiEndpoints +import fr.speekha.httpmocker.jackson.JacksonMapper +import io.mockk.* import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.runBlockingTest +import org.junit.Assert.assertEquals import org.junit.Before -import org.junit.Rule import org.junit.Test @ExperimentalCoroutinesApi -class MainViewModelTest { - - @get:Rule - var coroutinesTestRule = CoroutinesTestRule() - @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() +class MainViewModelTest : ViewModelTest() { private val org = "kotlin" private val repo = "repo" @@ -31,9 +23,11 @@ class MainViewModelTest { private val contributions = 1 private val id = 0L - private val mockService = mock() - private val mockResponseInterceptor = - MockResponseInterceptor.Builder().parseScenariosWith(mock()).build() + private val mockService = mockk() + private val mockResponseInterceptor = MockResponseInterceptor.Builder() + .parseScenariosWith(JacksonMapper()) + .build() + private lateinit var viewModel: MainViewModel @Before @@ -42,93 +36,97 @@ class MainViewModelTest { } @Test - fun `should load repos and top contributors successfully`() { - val observer = mock>() + fun `should succeed repos and top contributors calls`() = runBlockingTest { + val observer = spyk>() viewModel.getData().observeForever(observer) - coroutinesTestRule.testDispatcher.runBlockingTest { - whenever(mockService.listRepositoriesForOrganisation(org)) - .thenReturn(listOf(Repo(id, repo, topContributor = contributor))) - whenever(mockService.listContributorsForRepository(org, repo)) - .thenReturn(listOf(User(login = contributor, contributions = contributions))) - viewModel.callService() - } - - verify(observer).onChanged(Data.Loading) - verify(observer).onChanged( - Data.Success( - listOf( - Repo( - id, - repo, - topContributor = "$contributor - $contributions contributions" + coEvery { mockService.listRepositoriesForOrganisation(org) } returns + listOf(Repo(id, repo, topContributor = contributor)) + coEvery { mockService.listContributorsForRepository(org, repo) } returns + listOf(User(login = contributor, contributions = contributions)) + + viewModel.callService() + + coVerifyOrder { + observer.onChanged(Data.Loading) + observer.onChanged( + Data.Success( + listOf( + Repo( + id, + repo, + topContributor = "$contributor - $contributions contributions" + ) ) ) ) - ) + } + confirmVerified(observer) viewModel.getData().removeObserver(observer) } @Test - fun `should load repos successfully and fail top contributors`() { - val observer = mock>() + fun `should succeed repos call and fail top contributors call`() = runBlockingTest { + val observer = spyk>() viewModel.getData().observeForever(observer) + coEvery { mockService.listRepositoriesForOrganisation(org) } returns + listOf(Repo(id, repo, topContributor = contributor)) + coEvery { mockService.listContributorsForRepository(org, repo) } returns emptyList() - coroutinesTestRule.testDispatcher.runBlockingTest { - whenever(mockService.listRepositoriesForOrganisation(org)) - .thenReturn(listOf(Repo(id, repo, topContributor = contributor))) - whenever(mockService.listContributorsForRepository(org, repo)) - .thenReturn(null) - viewModel.callService() - } + viewModel.callService() - verify(observer).onChanged(Data.Loading) - verify(observer).onChanged( - Data.Success( - listOf(Repo(id, repo)) + coVerifyOrder { + observer.onChanged(Data.Loading) + observer.onChanged( + Data.Success(listOf(Repo(id, repo))) ) - ) + } + confirmVerified(observer) viewModel.getData().removeObserver(observer) } @Test fun `should update state according to disabled mode`() { - val observer = mock>() + val observer = spyk>() viewModel.getState().observeForever(observer) viewModel.setMode(MockResponseInterceptor.Mode.DISABLED) - verify(observer).onChanged(State.Message(R.string.disabled_description)) + assertEquals(mockResponseInterceptor.mode, MockResponseInterceptor.Mode.DISABLED) + verify { observer.onChanged(State.Message(R.string.disabled_description)) } } @Test fun `should update state according to enabled mode`() { - val observer = mock>() + val observer = spyk>() viewModel.getState().observeForever(observer) viewModel.setMode(MockResponseInterceptor.Mode.ENABLED) - verify(observer).onChanged(State.Message(R.string.enabled_description)) + assertEquals(mockResponseInterceptor.mode, MockResponseInterceptor.Mode.ENABLED) + verify { observer.onChanged(State.Message(R.string.enabled_description)) } } @Test fun `should update state according to mixed mode`() { - val observer = mock>() + val observer = spyk>() viewModel.getState().observeForever(observer) viewModel.setMode(MockResponseInterceptor.Mode.MIXED) - verify(observer).onChanged(State.Message(R.string.mixed_description)) + assertEquals(mockResponseInterceptor.mode, MockResponseInterceptor.Mode.MIXED) + verify { observer.onChanged(State.Message(R.string.mixed_description)) } } @Test fun `should check permission and update state according to record mode`() { - val observer = mock>() + val observer = spyk>() viewModel.getState().observeForever(observer) viewModel.setMode(MockResponseInterceptor.Mode.RECORD) - verify(observer).onChanged(State.Permission) - verify(observer).onChanged(State.Message(R.string.record_description)) + assertEquals(mockResponseInterceptor.mode, MockResponseInterceptor.Mode.RECORD) + verify { observer.onChanged(State.Permission) } + verify { observer.onChanged(State.Message(R.string.record_description)) } } } diff --git a/demo/src/test/java/fr/speekha/httpmocker/demo/ui/ViewModelTest.kt b/demo/src/test/java/fr/speekha/httpmocker/demo/ui/ViewModelTest.kt new file mode 100644 index 00000000..1a1971dc --- /dev/null +++ b/demo/src/test/java/fr/speekha/httpmocker/demo/ui/ViewModelTest.kt @@ -0,0 +1,19 @@ +package fr.speekha.httpmocker.demo.ui + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineScope +import kotlinx.coroutines.test.runBlockingTest +import org.junit.Rule + +@ExperimentalCoroutinesApi +open class ViewModelTest { + + @get:Rule + var coroutinesTestRule = CoroutinesTestRule() + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + fun runBlockingTest(block: suspend TestCoroutineScope.() -> Unit) = + coroutinesTestRule.testDispatcher.runBlockingTest(block) +} \ No newline at end of file From 4bb13387d9b1190aa1b61c47ca28991dc544de46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ste=CC=81phane=20Baiget?= Date: Sat, 3 Aug 2019 14:25:34 +0200 Subject: [PATCH 22/32] Improve result management. --- .../speekha/httpmocker/demo/model/Result.kt | 63 +++++++++++++++++++ .../httpmocker/demo/ui/MainViewModel.kt | 36 ++++++----- .../speekha/httpmocker/demo/ui/RepoAdapter.kt | 1 + .../httpmocker/demo/ui/MainViewModelTest.kt | 23 ++++++- 4 files changed, 105 insertions(+), 18 deletions(-) create mode 100644 demo/src/main/java/fr/speekha/httpmocker/demo/model/Result.kt diff --git a/demo/src/main/java/fr/speekha/httpmocker/demo/model/Result.kt b/demo/src/main/java/fr/speekha/httpmocker/demo/model/Result.kt new file mode 100644 index 00000000..df0894bf --- /dev/null +++ b/demo/src/main/java/fr/speekha/httpmocker/demo/model/Result.kt @@ -0,0 +1,63 @@ +package fr.speekha.httpmocker.demo.model + +class Result private constructor(private val result: Any?) { + + val isFailure: Boolean get() = result is Failure + val isSuccess: Boolean get() = result !is Failure + + fun get(): T = + if (result is Failure) throw result.exception + else result as T + + fun getOrNull(): T? = + if (result is Failure) null + else result as T + + inline fun getOrElse(default: () -> T): T = + if (isFailure) default() + else value + + fun exceptionOrNull(): Throwable? = + if (result is Failure) result.exception + else null + + companion object { + fun success(value: T): Result = Result(value) + fun failure(exception: Throwable) = Result(Failure(exception)) + } + + @PublishedApi + internal val exception: Throwable + get() = (result as Failure).exception + + @PublishedApi + internal val value: T + get() = result as T + + private class Failure(@JvmField val exception: Throwable) +} + +inline fun resultOf(block: () -> T): Result = + try { + Result.success(block()) + } catch (e: Throwable) { + Result.failure(e) + } + +inline fun Result.map(block: (T) -> U): Result = + if (isFailure) this as Result + else resultOf { block(value) } + +inline fun Result.handle(block: (Throwable) -> U): Result = + if (isFailure) resultOf { block(exception) } + else this as Result + +inline fun Result.onFailure(block: (Throwable) -> Unit): Result { + if (isFailure) block(exception) + return this +} + +inline fun Result.onSuccess(block: (T) -> Unit): Result { + if (isSuccess) block(value) + return this +} diff --git a/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainViewModel.kt b/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainViewModel.kt index db54c533..338bb792 100644 --- a/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainViewModel.kt +++ b/demo/src/main/java/fr/speekha/httpmocker/demo/ui/MainViewModel.kt @@ -9,8 +9,10 @@ import androidx.lifecycle.viewModelScope import fr.speekha.httpmocker.MockResponseInterceptor import fr.speekha.httpmocker.demo.R import fr.speekha.httpmocker.demo.model.Repo +import fr.speekha.httpmocker.demo.model.onFailure +import fr.speekha.httpmocker.demo.model.onSuccess +import fr.speekha.httpmocker.demo.model.resultOf import fr.speekha.httpmocker.demo.service.GithubApiEndpoints -import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -22,23 +24,26 @@ class MainViewModel( private val data = MutableLiveData() private val state = MutableLiveData() - private val exceptionHandler = CoroutineExceptionHandler { _, exception -> - data.postValue(Data.Error(exception.message)) - } fun getData(): LiveData = data fun getState(): LiveData = state fun callService() { - viewModelScope.launch(exceptionHandler) { + viewModelScope.launch { data.postValue(Data.Loading) val org = "kotlin" - val repos = loadRepos(org) - .map { - val contributor = loadTopContributor(org, it.name)?.firstOrNull() - it.copy(topContributor = contributor?.run { "$login - $contributions contributions" }) + loadRepos(org) + .onSuccess { repos -> + repos.map { repo -> + val contributor = + loadTopContributor(org, repo.name).getOrNull()?.firstOrNull() + repo.copy(topContributor = contributor?.run { "$login - $contributions contributions" }) + }.also { + data.postValue(Data.Success(it)) + } + }.onFailure { + data.postValue(Data.Error(it.message)) } - data.postValue(Data.Success(repos)) } } @@ -60,16 +65,17 @@ class MainViewModel( } private suspend fun loadRepos(org: String) = withContext(Dispatchers.IO) { - apiService.listRepositoriesForOrganisation(org) + resultOf { + apiService.listRepositoriesForOrganisation(org) + } } private suspend fun loadTopContributor(org: String, repo: String) = withContext(Dispatchers.IO) { - try { + resultOf { apiService.listContributorsForRepository(org, repo) - } catch (e: Throwable) { - Log.e("ViewModel", e.message, e) - null + }.onFailure { + Log.e("ViewModel", it.message, it) } } } diff --git a/demo/src/main/java/fr/speekha/httpmocker/demo/ui/RepoAdapter.kt b/demo/src/main/java/fr/speekha/httpmocker/demo/ui/RepoAdapter.kt index fc2e4e1d..5c845aec 100644 --- a/demo/src/main/java/fr/speekha/httpmocker/demo/ui/RepoAdapter.kt +++ b/demo/src/main/java/fr/speekha/httpmocker/demo/ui/RepoAdapter.kt @@ -49,6 +49,7 @@ class RepoAdapter( topContributor.text = repo.topContributor ?: "Error retrieving contributor" } else { repoName.text = errorMessage ?: "No result to display" + topContributor.text = null } } diff --git a/demo/src/test/java/fr/speekha/httpmocker/demo/ui/MainViewModelTest.kt b/demo/src/test/java/fr/speekha/httpmocker/demo/ui/MainViewModelTest.kt index 0e71dcf7..a7f9efe2 100644 --- a/demo/src/test/java/fr/speekha/httpmocker/demo/ui/MainViewModelTest.kt +++ b/demo/src/test/java/fr/speekha/httpmocker/demo/ui/MainViewModelTest.kt @@ -77,9 +77,26 @@ class MainViewModelTest : ViewModelTest() { coVerifyOrder { observer.onChanged(Data.Loading) - observer.onChanged( - Data.Success(listOf(Repo(id, repo))) - ) + observer.onChanged(Data.Success(listOf(Repo(id, repo)))) + } + confirmVerified(observer) + viewModel.getData().removeObserver(observer) + } + + + @Test + fun `should fail repos call`() = runBlockingTest { + val errorMessage = "error" + val observer = spyk>() + viewModel.getData().observeForever(observer) + coEvery { mockService.listRepositoriesForOrganisation(org) } throws + IllegalStateException(errorMessage) + + viewModel.callService() + + coVerifyOrder { + observer.onChanged(Data.Loading) + observer.onChanged(Data.Error(errorMessage)) } confirmVerified(observer) viewModel.getData().removeObserver(observer) From 636a8af1d964b09d8f3b128f251b4b1628a910e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ste=CC=81phane=20Baiget?= Date: Sat, 3 Aug 2019 15:21:50 +0200 Subject: [PATCH 23/32] Correct unit test. --- .../java/fr/speekha/httpmocker/demo/ui/MainViewModelTest.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/demo/src/test/java/fr/speekha/httpmocker/demo/ui/MainViewModelTest.kt b/demo/src/test/java/fr/speekha/httpmocker/demo/ui/MainViewModelTest.kt index a7f9efe2..8f1e050c 100644 --- a/demo/src/test/java/fr/speekha/httpmocker/demo/ui/MainViewModelTest.kt +++ b/demo/src/test/java/fr/speekha/httpmocker/demo/ui/MainViewModelTest.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test +import java.io.IOException @ExperimentalCoroutinesApi @@ -71,7 +72,7 @@ class MainViewModelTest : ViewModelTest() { viewModel.getData().observeForever(observer) coEvery { mockService.listRepositoriesForOrganisation(org) } returns listOf(Repo(id, repo, topContributor = contributor)) - coEvery { mockService.listContributorsForRepository(org, repo) } returns emptyList() + coEvery { mockService.listContributorsForRepository(org, repo) } throws IOException() viewModel.callService() @@ -89,8 +90,7 @@ class MainViewModelTest : ViewModelTest() { val errorMessage = "error" val observer = spyk>() viewModel.getData().observeForever(observer) - coEvery { mockService.listRepositoriesForOrganisation(org) } throws - IllegalStateException(errorMessage) + coEvery { mockService.listRepositoriesForOrganisation(org) } throws IOException(errorMessage) viewModel.callService() From f5aae5f827c603971f3a9f06ecd4b650cce981bc Mon Sep 17 00:00:00 2001 From: David Blanc Date: Mon, 5 Aug 2019 12:12:00 +0200 Subject: [PATCH 24/32] Extract request comparison Handle null params and headers (+ related bug fixes) --- .../httpmocker/custom/JsonSerialization.kt | 2 +- .../httpmocker/custom/JsonStringReader.kt | 42 ++++++++---- .../speekha/httpmocker/custom/MapAdapter.kt | 6 +- .../httpmocker/custom/ResponseAdapter.kt | 4 +- .../fr/speekha/httpmocker/gson/GsonMapper.kt | 31 +++++++-- .../fr/speekha/httpmocker/gson/Header.kt | 2 +- .../speekha/httpmocker/gson/HeaderAdapter.kt | 10 ++- .../speekha/httpmocker/gson/ParamsAdapter.kt | 57 ++++++++++++++++ .../httpmocker/gson/RequestDescriptor.kt | 2 +- .../fr/speekha/httpmocker/jackson/Header.kt | 2 +- .../httpmocker/jackson/RequestDescriptor.kt | 3 +- .../httpmocker/kotlinx/JsonFormatConverter.kt | 4 +- .../httpmocker/kotlinx/KotlinxMapper.kt | 28 +++++--- .../httpmocker/kotlinx/RequestDescriptor.kt | 2 +- .../httpmocker/MockResponseInterceptor.kt | 4 +- .../fr/speekha/httpmocker/model/Header.kt | 2 +- .../httpmocker/model/RequestDescriptor.kt | 2 +- .../httpmocker/scenario/RequestMatcher.kt | 68 +++++++++++++++++++ .../httpmocker/scenario/StaticMockProvider.kt | 21 +----- .../fr/speekha/httpmocker/moshi/Header.kt | 4 +- .../speekha/httpmocker/moshi/HeaderAdapter.kt | 11 ++- .../httpmocker/moshi/MatcherAdapter.kt | 4 +- .../speekha/httpmocker/moshi/MoshiMapper.kt | 1 + .../speekha/httpmocker/moshi/ParamAdapter.kt | 55 +++++++++++++++ .../httpmocker/moshi/RequestDescriptor.kt | 3 +- .../httpmocker/moshi/ResponseDescriptor.kt | 1 - .../httpmocker/interceptor/StaticMockTests.kt | 53 +++++++++++++-- .../mappers/JsonStringReaderTest.kt | 44 ++++++------ .../fr/speekha/httpmocker/mappers/TestData.kt | 3 +- tests/src/test/resources/absent_header.json | 13 ++++ .../test/resources/absent_query_param.json | 13 ++++ tests/src/test/resources/complete_input.json | 4 +- tests/src/test/resources/exact_match.json | 13 +++- tests/src/test/resources/headers.json | 20 ++++++ 34 files changed, 429 insertions(+), 105 deletions(-) create mode 100644 gson-adapter/src/main/kotlin/fr/speekha/httpmocker/gson/ParamsAdapter.kt create mode 100644 mocker/src/main/kotlin/fr/speekha/httpmocker/scenario/RequestMatcher.kt create mode 100644 moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/ParamAdapter.kt create mode 100644 tests/src/test/resources/absent_header.json create mode 100644 tests/src/test/resources/absent_query_param.json diff --git a/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonSerialization.kt b/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonSerialization.kt index 6748218b..b11817e3 100644 --- a/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonSerialization.kt +++ b/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonSerialization.kt @@ -56,7 +56,7 @@ internal fun RequestDescriptor.toJson(): String = listOf( postfix = "\n }" ) { (key, value) -> " \"$key\": $value" } -internal fun Map.toJson(): String = +internal fun Map.toJson(): String = entries.joinToString( separator = ",\n", prefix = "{\n", diff --git a/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonStringReader.kt b/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonStringReader.kt index af2a8fee..0d990a7b 100644 --- a/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonStringReader.kt +++ b/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonStringReader.kt @@ -105,7 +105,7 @@ class JsonStringReader( parseError(NO_FIELD_ID_ERROR) } else { index = colon - return stringLiteral + return stringLiteral ?: parseError(NO_FIELD_ID_ERROR) } } @@ -132,15 +132,7 @@ class JsonStringReader( * Reads a String field value * @return the field value as a String */ - fun readString(): String { - val start = json.indexOf("\"", index) - if (start < index || !isBlank(index, start)) { - parseError(WRONG_START_OF_STRING_FIELD_ERROR) - } else { - index = start - } - return extractStringLiteral() - } + fun readString(): String? = extractNullableStringLiteral() /** * Reads an object field value @@ -160,7 +152,11 @@ class JsonStringReader( it.replace(" ", "").convert() } - private fun parseToken(pattern: Pattern, error: String, converter: (String) -> T): T { + private fun parseToken( + pattern: Pattern, + error: String, + converter: (String) -> T + ): T { val position = index return try { converter(extractLiteral(pattern, error)) @@ -175,7 +171,17 @@ class JsonStringReader( else -> error(INVALID_BOOLEAN_ERROR) } - private fun extractStringLiteral(): String { + private fun extractNullableStringLiteral(): String? = + parseToken(stringPattern, WRONG_START_OF_STRING_FIELD_ERROR) { + val trimmed = it.trim() + when { + "null" == trimmed -> null + trimmed.startsWith('"') && trimmed.endsWith('"') -> trimmed.drop(1).dropLast(1).replace("\\\"", "\"") + else -> parseError(WRONG_START_OF_STRING_FIELD_ERROR) + } + } + + private fun extractStringLiteral(): String? { val start = json.indexOf("\"", index) val match = Regex("[^\\\\]\"").find(json, start) val end = match?.range?.endInclusive ?: -1 @@ -186,7 +192,10 @@ class JsonStringReader( return json.substring(start + 1, end).replace("\\\"", "\"") } - private fun extractLiteral(pattern: Pattern, error: String = INVALID_TOKEN_ERROR): String { + private fun extractLiteral( + pattern: Pattern, + error: String = INVALID_TOKEN_ERROR + ): String { val matcher = pattern.matcher(json.substring(index)) if (!matcher.find() || !isBlank(index, index + matcher.start())) { parseError(error) @@ -195,14 +204,16 @@ class JsonStringReader( return matcher.group() } - private fun isFieldSeparator(start: Int, end: Int) = json.substring(start, end).trim() == ":" + private fun isFieldSeparator(start: Int, end: Int) = + json.substring(start, end).trim() == ":" private fun isBlank(start: Int, end: Int) = json.substring(start, end).isBlank() private fun parseError(message: String, position: Int = index): Nothing = error("$message${extractAfterCurrentPosition(position)}") - private fun extractAfterCurrentPosition(position: Int) = json.substring(position).truncate(10) + private fun extractAfterCurrentPosition(position: Int) = + json.substring(position).truncate(10) } @@ -219,3 +230,4 @@ const val INVALID_BOOLEAN_ERROR = "Invalid boolean value: " private val numericPattern = Pattern.compile("\\d[\\d ]*") private val alphanumericPattern = Pattern.compile("[^,}\\]\\s]+") +private val stringPattern = Pattern.compile("[^,}\\]]+") diff --git a/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/MapAdapter.kt b/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/MapAdapter.kt index 54e204b5..54e798f4 100644 --- a/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/MapAdapter.kt +++ b/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/MapAdapter.kt @@ -16,12 +16,12 @@ package fr.speekha.httpmocker.custom -internal class MapAdapter : BaseObjectAdapter>() { +internal class MapAdapter : BaseObjectAdapter>() { override fun createObject(): Map = mapOf() override fun updateObject( reader: JsonStringReader, - builder: Map - ): Map = builder + (reader.readFieldName() to reader.readString()) + builder: Map + ): Map = builder + (reader.readFieldName() to reader.readString()) } diff --git a/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/ResponseAdapter.kt b/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/ResponseAdapter.kt index 2714e67b..9ca5895a 100644 --- a/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/ResponseAdapter.kt +++ b/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/ResponseAdapter.kt @@ -28,9 +28,9 @@ internal class ResponseAdapter : BaseObjectAdapter() { ): ResponseDescriptor = when (val field = reader.readFieldName()) { "delay" -> builder.copy(delay = reader.readLong()) "code" -> builder.copy(code = reader.readInt()) - "media-type" -> builder.copy(mediaType = reader.readString()) + "media-type" -> builder.copy(mediaType = reader.readString() ?: "") "headers" -> builder.copy(headers = reader.readObject(HeaderListAdapter())) - "body" -> builder.copy(body = reader.readString()) + "body" -> builder.copy(body = reader.readString() ?: "") "body-file" -> builder.copy(bodyFile = reader.readString()) else -> error("Unknown field $field") } diff --git a/gson-adapter/src/main/kotlin/fr/speekha/httpmocker/gson/GsonMapper.kt b/gson-adapter/src/main/kotlin/fr/speekha/httpmocker/gson/GsonMapper.kt index 4c83e4a8..3febf9b2 100644 --- a/gson-adapter/src/main/kotlin/fr/speekha/httpmocker/gson/GsonMapper.kt +++ b/gson-adapter/src/main/kotlin/fr/speekha/httpmocker/gson/GsonMapper.kt @@ -35,16 +35,17 @@ import fr.speekha.httpmocker.gson.ResponseDescriptor as JsonResponseDescriptor */ class GsonMapper : Mapper { - private val adapter: Gson = GsonBuilder() + private val gson: Gson = GsonBuilder() .setPrettyPrinting() .disableHtmlEscaping() .registerTypeAdapter(HeaderAdapter.HeaderList::class.java, HeaderAdapter()) + .registerTypeAdapter(ParamsAdapter.ParamList::class.java, ParamsAdapter()) .create() private val dataType = MatcherType().type override fun deserialize(payload: String): List = - adapter.parse(payload).map { + gson.parse(payload).map { it.toModel() } @@ -52,7 +53,7 @@ class GsonMapper : Mapper { fromJson>(json, dataType) ?: emptyList() override fun serialize(matchers: List): String = - adapter.toJson(matchers.map { it.fromModel() }) + gson.toJson(matchers.map { it.fromModel() }) private class MatcherType : TypeToken>() @@ -62,10 +63,30 @@ class GsonMapper : Mapper { Matcher(request?.toModel() ?: RequestDescriptor(), response.toModel()) private fun JsonRequestDescriptor.toModel() = - RequestDescriptor(exactMatch ?: false, protocol, method, host, port, path, headers.toModel(), params, body) + RequestDescriptor( + exactMatch ?: false, + protocol, + method, + host, + port, + path, + headers.toModel(), + params.associate { it }, + body + ) private fun RequestDescriptor.fromModel() = - JsonRequestDescriptor(exactMatch.takeIf { it }, protocol, method, host, port, path, getHeaders(), params, body) + JsonRequestDescriptor( + exactMatch.takeIf { it }, + protocol, + method, + host, + port, + path, + getHeaders(), + ParamsAdapter.ParamList(params), + body + ) private fun RequestDescriptor.getHeaders() = HeaderAdapter.HeaderList(headers.map { it.fromModel() }) diff --git a/gson-adapter/src/main/kotlin/fr/speekha/httpmocker/gson/Header.kt b/gson-adapter/src/main/kotlin/fr/speekha/httpmocker/gson/Header.kt index 1a6c9f24..df4e1d65 100644 --- a/gson-adapter/src/main/kotlin/fr/speekha/httpmocker/gson/Header.kt +++ b/gson-adapter/src/main/kotlin/fr/speekha/httpmocker/gson/Header.kt @@ -19,5 +19,5 @@ package fr.speekha.httpmocker.gson internal data class Header( val name: String = "", - var value: String = "" + var value: String? = null ) \ No newline at end of file diff --git a/gson-adapter/src/main/kotlin/fr/speekha/httpmocker/gson/HeaderAdapter.kt b/gson-adapter/src/main/kotlin/fr/speekha/httpmocker/gson/HeaderAdapter.kt index ff8d110c..3178b672 100644 --- a/gson-adapter/src/main/kotlin/fr/speekha/httpmocker/gson/HeaderAdapter.kt +++ b/gson-adapter/src/main/kotlin/fr/speekha/httpmocker/gson/HeaderAdapter.kt @@ -18,6 +18,7 @@ package fr.speekha.httpmocker.gson import com.google.gson.TypeAdapter import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonToken import com.google.gson.stream.JsonWriter import fr.speekha.httpmocker.gson.Header as JsonHeader @@ -28,10 +29,12 @@ internal class HeaderAdapter : TypeAdapter() { override fun write(writer: JsonWriter?, headers: HeaderList?) { writer?.run { beginObject() + serializeNulls = true headers?.forEach { name(it.name) value(it.value) } + serializeNulls = false endObject() } } @@ -41,7 +44,12 @@ internal class HeaderAdapter : TypeAdapter() { beginObject() while (hasNext()) { val name = nextName() - val value = nextString() + val value = if (peek() == JsonToken.NULL) { + nextNull() + null + } else { + nextString() + } list += JsonHeader(name, value) } endObject() diff --git a/gson-adapter/src/main/kotlin/fr/speekha/httpmocker/gson/ParamsAdapter.kt b/gson-adapter/src/main/kotlin/fr/speekha/httpmocker/gson/ParamsAdapter.kt new file mode 100644 index 00000000..937a0bf5 --- /dev/null +++ b/gson-adapter/src/main/kotlin/fr/speekha/httpmocker/gson/ParamsAdapter.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2019 David Blanc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.speekha.httpmocker.gson + +import com.google.gson.TypeAdapter +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonToken +import com.google.gson.stream.JsonWriter + +internal class ParamsAdapter : TypeAdapter() { + + class ParamList(map: Map = emptyMap()) : ArrayList>(map.entries.map { it.key to it.value }) + + override fun write(writer: JsonWriter?, params: ParamList?) { + writer?.run { + beginObject() + serializeNulls = true + params?.forEach { + name(it.first) + value(it.second) + } + serializeNulls = false + endObject() + } + } + + override fun read(reader: JsonReader?): ParamList = reader?.run { + val list = ParamList() + beginObject() + while (hasNext()) { + val name = nextName() + val value = if (peek() == JsonToken.NULL) { + nextNull() + null + } else { + nextString() + } + list += name to value + } + endObject() + list + } ?: ParamList() +} \ No newline at end of file diff --git a/gson-adapter/src/main/kotlin/fr/speekha/httpmocker/gson/RequestDescriptor.kt b/gson-adapter/src/main/kotlin/fr/speekha/httpmocker/gson/RequestDescriptor.kt index c34d32e9..42872e77 100644 --- a/gson-adapter/src/main/kotlin/fr/speekha/httpmocker/gson/RequestDescriptor.kt +++ b/gson-adapter/src/main/kotlin/fr/speekha/httpmocker/gson/RequestDescriptor.kt @@ -35,7 +35,7 @@ internal data class RequestDescriptor( val headers: HeaderAdapter.HeaderList? = HeaderAdapter.HeaderList(), - val params: Map = emptyMap(), + val params: ParamsAdapter.ParamList = ParamsAdapter.ParamList(), val body: String? = null diff --git a/jackson-adapter/src/main/kotlin/fr/speekha/httpmocker/jackson/Header.kt b/jackson-adapter/src/main/kotlin/fr/speekha/httpmocker/jackson/Header.kt index 46bc2d13..d330ccb5 100644 --- a/jackson-adapter/src/main/kotlin/fr/speekha/httpmocker/jackson/Header.kt +++ b/jackson-adapter/src/main/kotlin/fr/speekha/httpmocker/jackson/Header.kt @@ -25,5 +25,5 @@ internal data class Header val name: String = "", @JsonProperty("value") - var value: String = "" + var value: String? = null ) \ No newline at end of file diff --git a/jackson-adapter/src/main/kotlin/fr/speekha/httpmocker/jackson/RequestDescriptor.kt b/jackson-adapter/src/main/kotlin/fr/speekha/httpmocker/jackson/RequestDescriptor.kt index 5a3cc0c0..bac4228e 100644 --- a/jackson-adapter/src/main/kotlin/fr/speekha/httpmocker/jackson/RequestDescriptor.kt +++ b/jackson-adapter/src/main/kotlin/fr/speekha/httpmocker/jackson/RequestDescriptor.kt @@ -51,7 +51,8 @@ constructor( val headers: List
= emptyList(), @JsonProperty("params") - val params: Map = emptyMap(), + @JsonInclude(JsonInclude.Include.ALWAYS) + val params: Map = emptyMap(), @JsonProperty("body") val body: String? = null diff --git a/kotlinx-adapter/src/main/kotlin/fr/speekha/httpmocker/kotlinx/JsonFormatConverter.kt b/kotlinx-adapter/src/main/kotlin/fr/speekha/httpmocker/kotlinx/JsonFormatConverter.kt index 91dce6bc..19b02105 100644 --- a/kotlinx-adapter/src/main/kotlin/fr/speekha/httpmocker/kotlinx/JsonFormatConverter.kt +++ b/kotlinx-adapter/src/main/kotlin/fr/speekha/httpmocker/kotlinx/JsonFormatConverter.kt @@ -108,7 +108,7 @@ class JsonFormatConverter { private fun mapInputHeader(header: String): String = with(separatorPattern.matcher(header)) { if (find()) { val key = header.substring(0..start()).trim() - val value = header.substring(end() - 1).trim() + val value = header.substring(end()).trim() """ | | { @@ -125,6 +125,6 @@ class JsonFormatConverter { Pattern.compile("\"headers\"\\p{Space}*:\\p{Space}*\\[[^]]*]") private val inputHeaderPattern = Pattern.compile("\"headers\"\\p{Space}*:\\p{Space}*\\{[^}]*}") - private val separatorPattern = Pattern.compile("\"\\p{Space}*:\\p{Space}*\"") + private val separatorPattern = Pattern.compile("\"\\p{Space}*:\\p{Space}*") } } diff --git a/kotlinx-adapter/src/main/kotlin/fr/speekha/httpmocker/kotlinx/KotlinxMapper.kt b/kotlinx-adapter/src/main/kotlin/fr/speekha/httpmocker/kotlinx/KotlinxMapper.kt index 0cd7fa8d..c6f17275 100644 --- a/kotlinx-adapter/src/main/kotlin/fr/speekha/httpmocker/kotlinx/KotlinxMapper.kt +++ b/kotlinx-adapter/src/main/kotlin/fr/speekha/httpmocker/kotlinx/KotlinxMapper.kt @@ -17,7 +17,6 @@ package fr.speekha.httpmocker.kotlinx import fr.speekha.httpmocker.Mapper -import fr.speekha.httpmocker.model.Header import fr.speekha.httpmocker.model.Matcher import fr.speekha.httpmocker.model.RequestDescriptor import fr.speekha.httpmocker.model.ResponseDescriptor @@ -28,6 +27,7 @@ import kotlinx.serialization.json.JsonLiteral import kotlinx.serialization.list import kotlinx.serialization.modules.EmptyModule import fr.speekha.httpmocker.kotlinx.Matcher as JsonMatcher +import fr.speekha.httpmocker.model.Header as ModelHeader @UnstableDefault /** @@ -73,14 +73,14 @@ private fun JsonElement.toMatcher(): Matcher = private fun JsonElement?.toRequest(): RequestDescriptor = this?.run { RequestDescriptor( jsonObject["exact-match"]?.primitive?.boolean ?: false, - jsonObject["protocol"]?.asLiteral(), - jsonObject["method"]?.asLiteral(), - jsonObject["host"]?.asLiteral(), + jsonObject["protocol"]?.asNullableLiteral(), + jsonObject["method"]?.asNullableLiteral(), + jsonObject["host"]?.asNullableLiteral(), jsonObject["port"]?.primitive?.int, - jsonObject["path"]?.asLiteral(), + jsonObject["path"]?.asNullableLiteral(), jsonObject["headers"].toHeaders(), jsonObject["params"].toParams(), - jsonObject["body"]?.asLiteral() + jsonObject["body"]?.asNullableLiteral() ) } ?: RequestDescriptor() @@ -91,16 +91,22 @@ private fun JsonElement?.toResponse(): ResponseDescriptor = this?.run { jsonObject["media-type"]?.let { result = result.copy(mediaType = it.asLiteral()) } jsonObject["headers"]?.let { result = result.copy(headers = jsonObject["headers"].toHeaders()) } jsonObject["body"]?.let { result = result.copy(body = it.asLiteral()) } - jsonObject["body-file"]?.let { result = result.copy(bodyFile = it.asLiteral()) } + jsonObject["body-file"]?.let { result = result.copy(bodyFile = it.asNullableLiteral()) } result } ?: ResponseDescriptor() -private fun JsonElement?.toParams(): Map = this?.run { - jsonObject.mapValues { it.value.asLiteral() } +private fun JsonElement?.toParams(): Map = this?.run { + jsonObject.mapValues { it.value.asNullableLiteral() } } ?: mapOf() -private fun JsonElement?.toHeaders(): List
= this?.run { - jsonArray.map { Header(it.jsonObject["name"].asLiteral(), it.jsonObject["value"].asLiteral()) } +private fun JsonElement?.toHeaders(): List = this?.run { + jsonArray.map { + ModelHeader( + it.jsonObject["name"].asNullableLiteral() ?: error("Incorrect header name"), + it.jsonObject["value"].asNullableLiteral() + ) + } } ?: listOf() private fun JsonElement?.asLiteral(): String = (this as? JsonLiteral)?.body?.toString() ?: "" +private fun JsonElement?.asNullableLiteral(): String? = (this as? JsonLiteral)?.body?.toString() diff --git a/kotlinx-adapter/src/main/kotlin/fr/speekha/httpmocker/kotlinx/RequestDescriptor.kt b/kotlinx-adapter/src/main/kotlin/fr/speekha/httpmocker/kotlinx/RequestDescriptor.kt index 1c35f9ba..111c8a6a 100644 --- a/kotlinx-adapter/src/main/kotlin/fr/speekha/httpmocker/kotlinx/RequestDescriptor.kt +++ b/kotlinx-adapter/src/main/kotlin/fr/speekha/httpmocker/kotlinx/RequestDescriptor.kt @@ -30,7 +30,7 @@ internal data class RequestDescriptor( val port: Int? = null, val path: String? = null, val headers: List
? = null, - val params: Map? = null, + val params: Map? = null, val body: String? = null ) { constructor(model: Model) : this( diff --git a/mocker/src/main/kotlin/fr/speekha/httpmocker/MockResponseInterceptor.kt b/mocker/src/main/kotlin/fr/speekha/httpmocker/MockResponseInterceptor.kt index 3f5413d4..25433ada 100644 --- a/mocker/src/main/kotlin/fr/speekha/httpmocker/MockResponseInterceptor.kt +++ b/mocker/src/main/kotlin/fr/speekha/httpmocker/MockResponseInterceptor.kt @@ -120,7 +120,9 @@ private constructor( private fun Response.Builder.addHeaders(response: ResponseDescriptor) = apply { header("Content-type", response.mediaType) response.headers.forEach { - header(it.name, it.value) + if (it.value != null) { + header(it.name, it.value) + } } } diff --git a/mocker/src/main/kotlin/fr/speekha/httpmocker/model/Header.kt b/mocker/src/main/kotlin/fr/speekha/httpmocker/model/Header.kt index fd8e0bb8..e0ff8489 100644 --- a/mocker/src/main/kotlin/fr/speekha/httpmocker/model/Header.kt +++ b/mocker/src/main/kotlin/fr/speekha/httpmocker/model/Header.kt @@ -29,6 +29,6 @@ data class Header( /** * Header value */ - var value: String = "" + val value: String? = null ) \ No newline at end of file diff --git a/mocker/src/main/kotlin/fr/speekha/httpmocker/model/RequestDescriptor.kt b/mocker/src/main/kotlin/fr/speekha/httpmocker/model/RequestDescriptor.kt index 37076a7f..b0883051 100644 --- a/mocker/src/main/kotlin/fr/speekha/httpmocker/model/RequestDescriptor.kt +++ b/mocker/src/main/kotlin/fr/speekha/httpmocker/model/RequestDescriptor.kt @@ -59,7 +59,7 @@ data class RequestDescriptor( /** * Query parameters */ - val params: Map = emptyMap(), + val params: Map = emptyMap(), /** * Request body diff --git a/mocker/src/main/kotlin/fr/speekha/httpmocker/scenario/RequestMatcher.kt b/mocker/src/main/kotlin/fr/speekha/httpmocker/scenario/RequestMatcher.kt new file mode 100644 index 00000000..38119240 --- /dev/null +++ b/mocker/src/main/kotlin/fr/speekha/httpmocker/scenario/RequestMatcher.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2019 David Blanc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.speekha.httpmocker.scenario + +import fr.speekha.httpmocker.matchBody +import fr.speekha.httpmocker.model.RequestDescriptor +import okhttp3.Request + +class RequestMatcher { + + fun matchRequest(descriptor: RequestDescriptor, request: Request): Boolean = with(descriptor) { + matchProtocol(request) && + matchMethod(request) && + matchHost(request) && + matchPort(request) && + matchPath(request) && + matchHeaders(request) && + matchParams(request) && + matchBody(request) + } + + private fun RequestDescriptor.matchBody(request: Request) = + request.matchBody(this) + + private fun RequestDescriptor.matchParams(request: Request) = + params.all { + request.url().queryParameter(it.key) == it.value + } && (!exactMatch || params.size == request.url().querySize()) + + private fun RequestDescriptor.matchHeaders(request: Request) = + headers.all { + if (it.value != null) { + request.headers(it.name).contains(it.value) + } else { + request.headers(it.name).isEmpty() + } + } && (!exactMatch || headers.size == request.headers().size()) + + private fun RequestDescriptor.matchPath(request: Request) = + (path?.let { it == request.url().encodedPath() } ?: true) + + private fun RequestDescriptor.matchPort(request: Request) = + (port?.let { it == request.url().port() } ?: true) + + private fun RequestDescriptor.matchHost(request: Request) = + (host?.equals(request.url().host(), true) ?: true) + + private fun RequestDescriptor.matchMethod(request: Request) = + (method?.equals(request.method(), true) ?: true) + + private fun RequestDescriptor.matchProtocol(request: Request) = + (protocol?.equals(request.url().scheme(), true) ?: true) + +} diff --git a/mocker/src/main/kotlin/fr/speekha/httpmocker/scenario/StaticMockProvider.kt b/mocker/src/main/kotlin/fr/speekha/httpmocker/scenario/StaticMockProvider.kt index da5d43d8..ae673b6a 100644 --- a/mocker/src/main/kotlin/fr/speekha/httpmocker/scenario/StaticMockProvider.kt +++ b/mocker/src/main/kotlin/fr/speekha/httpmocker/scenario/StaticMockProvider.kt @@ -19,13 +19,10 @@ package fr.speekha.httpmocker.scenario import fr.speekha.httpmocker.LoadFile import fr.speekha.httpmocker.Mapper import fr.speekha.httpmocker.getLogger -import fr.speekha.httpmocker.matchBody import fr.speekha.httpmocker.model.Matcher -import fr.speekha.httpmocker.model.RequestDescriptor import fr.speekha.httpmocker.model.ResponseDescriptor import fr.speekha.httpmocker.policies.FilingPolicy import okhttp3.Request -import java.util.Locale internal class StaticMockProvider( private val filingPolicy: FilingPolicy, @@ -35,6 +32,8 @@ internal class StaticMockProvider( private val logger = getLogger() + private val matcher = RequestMatcher() + override fun loadResponse(request: Request): ResponseDescriptor? = try { val path = filingPolicy.getPath(request) logger.info("Loading scenarios from $path") @@ -48,23 +47,9 @@ internal class StaticMockProvider( } private fun matchRequest(request: Request, list: List): ResponseDescriptor? = - list.firstOrNull { it.request.match(request) }?.response + list.firstOrNull { matcher.matchRequest(it.request, request) }?.response .also { logger.info(if (it != null) "Match found" else "No match for request") } - private fun RequestDescriptor.match(request: Request): Boolean = - (protocol?.equals(request.url().scheme(), true) ?: true) && - (method?.equals(request.method(), true) ?: true) && - (host?.equals(request.url().host(), true) ?: true) && - (port?.let { it == request.url().port() } ?: true) && - (path?.let { it == request.url().encodedPath() } ?: true) && - (host?.let { it.toLowerCase(Locale.ROOT) == request.url().host() } ?: true) && - (port?.let { it == request.url().port() } ?: true) && - (path?.let { it == request.url().encodedPath() } ?: true) && - headers.all { request.headers(it.name).contains(it.value) } && - params.all { request.url().queryParameter(it.key) == it.value } && - request.matchBody(this) && - (!exactMatch || (headers.size == request.headers().size() && params.size == request.url().querySize())) - override fun loadResponseBody(request: Request, path: String): ByteArray? = loadFileContent(getRelativePath(filingPolicy.getPath(request), path))?.readBytes() diff --git a/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/Header.kt b/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/Header.kt index a575d2fc..60404294 100644 --- a/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/Header.kt +++ b/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/Header.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package fr.speekha.httpmocker.moshikotlin +package fr.speekha.httpmocker.moshi import com.squareup.moshi.JsonClass @@ -22,5 +22,5 @@ import com.squareup.moshi.JsonClass internal data class Header( val name: String = "", - var value: String = "" + var value: String? = null ) \ No newline at end of file diff --git a/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/HeaderAdapter.kt b/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/HeaderAdapter.kt index 246518ec..3f66afe0 100644 --- a/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/HeaderAdapter.kt +++ b/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/HeaderAdapter.kt @@ -20,7 +20,7 @@ import com.squareup.moshi.FromJson import com.squareup.moshi.JsonReader import com.squareup.moshi.JsonWriter import com.squareup.moshi.ToJson -import fr.speekha.httpmocker.moshikotlin.Header as JsonHeader +import fr.speekha.httpmocker.moshi.Header as JsonHeader internal class HeaderAdapter { @@ -30,7 +30,12 @@ internal class HeaderAdapter { reader.beginObject() while (reader.hasNext()) { val name = reader.nextName() - val value = reader.nextString() + val value = if (reader.peek() != JsonReader.Token.NULL) { + reader.nextString() + } else { + reader.nextNull() + null + } list += JsonHeader(name, value) } reader.endObject() @@ -40,10 +45,12 @@ internal class HeaderAdapter { @ToJson fun headerToJson(writer: JsonWriter, headers: List) { writer.beginObject() + writer.serializeNulls = true headers.forEach { writer.name(it.name) writer.value(it.value) } + writer.serializeNulls = false writer.endObject() } } \ No newline at end of file diff --git a/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/MatcherAdapter.kt b/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/MatcherAdapter.kt index 0c4567d2..6ba05d65 100644 --- a/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/MatcherAdapter.kt +++ b/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/MatcherAdapter.kt @@ -22,10 +22,10 @@ import fr.speekha.httpmocker.model.Header import fr.speekha.httpmocker.model.Matcher import fr.speekha.httpmocker.model.RequestDescriptor import fr.speekha.httpmocker.model.ResponseDescriptor +import fr.speekha.httpmocker.moshi.Header as JsonHeader import fr.speekha.httpmocker.moshi.Matcher as JsonMatcher import fr.speekha.httpmocker.moshi.RequestDescriptor as JsonRequestDescriptor import fr.speekha.httpmocker.moshi.ResponseDescriptor as JsonResponseDescriptor -import fr.speekha.httpmocker.moshikotlin.Header as JsonHeader internal class MatcherAdapter { @FromJson @@ -34,7 +34,7 @@ internal class MatcherAdapter { } @ToJson - fun eventToJson(matcher: Matcher): JsonMatcher { + fun matcherToJson(matcher: Matcher): JsonMatcher { return JsonMatcher(requestToJson(matcher.request), responseToJson(matcher.response)) } diff --git a/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/MoshiMapper.kt b/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/MoshiMapper.kt index a5fad0d5..4860f066 100644 --- a/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/MoshiMapper.kt +++ b/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/MoshiMapper.kt @@ -34,6 +34,7 @@ class MoshiMapper : Mapper { val moshi = Moshi.Builder() .add(HeaderAdapter()) .add(MatcherAdapter()) + .add(ParamAdapter()) .build() adapter = moshi.adapter( newParameterizedType(List::class.java, Matcher::class.java) diff --git a/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/ParamAdapter.kt b/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/ParamAdapter.kt new file mode 100644 index 00000000..8997b7a4 --- /dev/null +++ b/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/ParamAdapter.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2019 David Blanc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.speekha.httpmocker.moshi + +import com.squareup.moshi.FromJson +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.ToJson + +internal class ParamAdapter { + + @FromJson + fun paramFromJson(reader: JsonReader): Map { + val map = mutableMapOf() + reader.beginObject() + while (reader.hasNext()) { + val name = reader.nextName() + val value = if (reader.peek() != JsonReader.Token.NULL) { + reader.nextString() + } else { + reader.nextNull() + null + } + map += name to value + } + reader.endObject() + return map + } + + @ToJson + fun paramToJson(writer: JsonWriter, headers: Map) { + writer.beginObject() + writer.serializeNulls = true + headers.forEach { + writer.name(it.key) + writer.value(it.value) + } + writer.serializeNulls = false + writer.endObject() + } +} \ No newline at end of file diff --git a/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/RequestDescriptor.kt b/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/RequestDescriptor.kt index 89e345cb..b50e9872 100644 --- a/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/RequestDescriptor.kt +++ b/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/RequestDescriptor.kt @@ -18,7 +18,6 @@ package fr.speekha.httpmocker.moshi import com.squareup.moshi.Json import com.squareup.moshi.JsonClass -import fr.speekha.httpmocker.moshikotlin.Header @JsonClass(generateAdapter = true) internal data class RequestDescriptor( @@ -38,7 +37,7 @@ internal data class RequestDescriptor( val headers: List
= emptyList(), - val params: Map = emptyMap(), + val params: Map = emptyMap(), val body: String? = null diff --git a/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/ResponseDescriptor.kt b/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/ResponseDescriptor.kt index 9c411ec5..29bab95c 100644 --- a/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/ResponseDescriptor.kt +++ b/moshi-adapter/src/main/kotlin/fr/speekha/httpmocker/moshi/ResponseDescriptor.kt @@ -18,7 +18,6 @@ package fr.speekha.httpmocker.moshi import com.squareup.moshi.Json import com.squareup.moshi.JsonClass -import fr.speekha.httpmocker.moshikotlin.Header @JsonClass(generateAdapter = true) internal data class ResponseDescriptor( diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/StaticMockTests.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/StaticMockTests.kt index d88735d4..166abe7f 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/StaticMockTests.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/StaticMockTests.kt @@ -234,6 +234,18 @@ class StaticMockTests : TestWithServer() { assertEquals("param B", param2) } + @ParameterizedTest(name = "{0}") + @MethodSource("data") + fun `should select response based on absent query params`(title: String, mapper: Mapper) { + setUpInterceptor(ENABLED, mapper) + + val param1 = executeGetRequest("/absent_query_param?param1=1").body()?.string() + val param2 = executeGetRequest("/absent_query_param?param1=1¶m2=2") + + assertEquals("Body found", param1) + assertEquals(404, param2.code()) + } + @ParameterizedTest(name = "{0}") @MethodSource("data") fun `should select response based on URL path`(title: String, mapper: Mapper) { @@ -299,8 +311,8 @@ class StaticMockTests : TestWithServer() { fun `should select response based on headers`(title: String, mapper: Mapper) { setUpInterceptor(ENABLED, mapper) - val param1 = executeGetRequest("/headers").body()?.string() - val param2 = executeGetRequest( + val noHeaders = executeGetRequest("/headers").body()?.string() + val headers = executeGetRequest( "/headers", listOf( "header1" to "1", @@ -308,9 +320,31 @@ class StaticMockTests : TestWithServer() { "header2" to "3" ) ).body()?.string() + val header1 = executeGetRequest( + "/headers", + listOf("header1" to "1") + ).body()?.string() + val header2 = executeGetRequest( + "/headers", + listOf("header2" to "2") + ).body()?.string() + + assertEquals("no header", noHeaders) + assertEquals("with header 1", header1) + assertEquals("with header 2", header2) + assertEquals("with headers", headers) + } + + @ParameterizedTest(name = "{0}") + @MethodSource("data") + fun `should select response based on absent headers`(title: String, mapper: Mapper) { + setUpInterceptor(ENABLED, mapper) + + val correctHeader = executeGetRequest("/absent_header", listOf("header1" to "1")).body()?.string() + val extraHeader = executeGetRequest("/absent_header", listOf("header1" to "1", "header2" to "2")) - assertEquals("no header", param1) - assertEquals("with headers", param2) + assertEquals("Body found", correctHeader) + assertEquals(404, extraHeader.code()) } @ParameterizedTest(name = "{0}") @@ -357,9 +391,16 @@ class StaticMockTests : TestWithServer() { fun `should select response based on exact matches`(title: String, mapper: Mapper) { setUpInterceptor(ENABLED, mapper) - val response = executeGetRequest("/exact_match?param1=1¶m2=2") + val exactHeader = executeGetRequest("/exact_match", listOf("header1" to "1")) + val extraHeader = + executeGetRequest("/exact_match", listOf("header1" to "1", "header2" to "2")) + val exactParam = executeGetRequest("/exact_match?param1=1") + val extraParam = executeGetRequest("/exact_match?param1=1¶m2=2") - assertEquals(404, response.code()) + assertEquals("Exact headers", exactHeader.body()?.string()) + assertEquals(404, extraHeader.code()) + assertEquals("Exact params", exactParam.body()?.string()) + assertEquals(404, extraParam.code()) } @ParameterizedTest(name = "{0}") diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JsonStringReaderTest.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JsonStringReaderTest.kt index ca106778..1deabece 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JsonStringReaderTest.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JsonStringReaderTest.kt @@ -176,7 +176,7 @@ class JsonStringReaderTest { @Test fun `should iterate through object`() { val reader = JsonStringReader(simpleObject) - val list = mutableListOf>() + val list = mutableListOf>() reader.beginObject() while (reader.hasNext()) { val field = reader.readFieldName() @@ -220,7 +220,7 @@ class JsonStringReaderTest { @Test fun `should iterate through list of strings`() { val json = "[\"1\", \"2\", \"3\"]" - val list = mutableListOf() + val list = mutableListOf() with(JsonStringReader(json)) { beginList() while (hasNext()) { @@ -234,11 +234,11 @@ class JsonStringReaderTest { @Test fun `should iterate through list`() { - val list = mutableListOf>() + val list = mutableListOf>() with(JsonStringReader(simpleList)) { beginList() while (hasNext()) { - val map = mutableMapOf() + val map = mutableMapOf() beginObject() while (hasNext()) { val field = readFieldName() @@ -264,7 +264,7 @@ class JsonStringReaderTest { fun `should detect when list is not entirely processed`() { with(JsonStringReader(simpleList)) { beginList() - val map = mutableMapOf() + val map = mutableMapOf() beginObject() while (hasNext()) { val field = readFieldName() @@ -307,16 +307,18 @@ class JsonStringReaderTest { @Test fun `should read object field`() { - val reader = JsonStringReader(complexObject) - val obj = mutableMapOf() - reader.beginObject() - obj[reader.readFieldName()] = reader.readString() - obj[reader.readFieldName()] = reader.readObject(mapAdapter) - reader.next() - assertEquals( - mapOf("field0" to "0", "object" to mapOf("field1" to "1", "field2" to "2")), - obj - ) + with(JsonStringReader(complexObject)) { + val obj = mutableMapOf() + beginObject() + obj[readFieldName()] = readString() ?: error("Incorrect object name") + next() + obj[readFieldName()] = readObject(mapAdapter) + next() + assertEquals( + mapOf("field0" to "0", "object" to mapOf("field1" to "1", "field2" to "2")), + obj + ) + } } @Test @@ -328,8 +330,8 @@ class JsonStringReaderTest { readObject(mapAdapter) } assertEquals( - "$WRONG_START_OF_OBJECT_ERROR \"0\"\n" + - " ...", exception.message + "$WRONG_START_OF_OBJECT_ERROR \"0\",\n" + + " ...", exception.message ) } } @@ -340,9 +342,9 @@ class JsonStringReaderTest { assertEquals(output, input.truncate(10)) } - private val mapAdapter = object : ObjectAdapter> { - override fun fromJson(reader: JsonStringReader): Map { - val map = mutableMapOf() + private val mapAdapter = object : ObjectAdapter> { + override fun fromJson(reader: JsonStringReader): Map { + val map = mutableMapOf() reader.beginObject() while (reader.hasNext()) { val field = reader.readFieldName() @@ -378,7 +380,7 @@ class JsonStringReaderTest { val complexObject = """ { - "field0" : "0" + "field0" : "0", "object" : { "field1": "1", "field2": "2" diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/TestData.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/TestData.kt index 974c8b59..d35df575 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/TestData.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/TestData.kt @@ -37,9 +37,10 @@ internal val completeData = listOf( Header("reqHeader1", "1"), Header("reqHeader1", "2"), Header("reqHeader2", "3"), + Header("reqHeader3", null), Header("Set-Cookie", "\"cookie\"=\"value\"") ), - params = mapOf("param1" to "1", "param2" to "2"), + params = mapOf("param1" to "1", "param2" to "2", "param3" to null), body = ".*1.*" ), ResponseDescriptor( diff --git a/tests/src/test/resources/absent_header.json b/tests/src/test/resources/absent_header.json new file mode 100644 index 00000000..2c48c440 --- /dev/null +++ b/tests/src/test/resources/absent_header.json @@ -0,0 +1,13 @@ +[ + { + "request": { + "headers": { + "header1": "1", + "header2": null + } + }, + "response": { + "body": "Body found" + } + } +] diff --git a/tests/src/test/resources/absent_query_param.json b/tests/src/test/resources/absent_query_param.json new file mode 100644 index 00000000..0a9783c4 --- /dev/null +++ b/tests/src/test/resources/absent_query_param.json @@ -0,0 +1,13 @@ +[ + { + "request": { + "params": { + "param1": "1", + "param2": null + } + }, + "response": { + "body": "Body found" + } + } +] diff --git a/tests/src/test/resources/complete_input.json b/tests/src/test/resources/complete_input.json index 36269da4..1d641a92 100644 --- a/tests/src/test/resources/complete_input.json +++ b/tests/src/test/resources/complete_input.json @@ -11,11 +11,13 @@ "reqHeader1": "1", "reqHeader1": "2", "reqHeader2": "3", + "reqHeader3": null, "Set-Cookie": "\"cookie\"=\"value\"" }, "params": { "param1": "1", - "param2": "2" + "param2": "2", + "param3": null }, "body": ".*1.*" }, diff --git a/tests/src/test/resources/exact_match.json b/tests/src/test/resources/exact_match.json index 54e9019b..ab36b5dd 100644 --- a/tests/src/test/resources/exact_match.json +++ b/tests/src/test/resources/exact_match.json @@ -1,4 +1,15 @@ [ + { + "request": { + "exact-match" : true, + "headers": { + "header1": "1" + } + }, + "response": { + "body": "Exact headers" + } + }, { "request": { "exact-match" : true, @@ -7,7 +18,7 @@ } }, "response": { - "body": "Found response" + "body": "Exact params" } } ] diff --git a/tests/src/test/resources/headers.json b/tests/src/test/resources/headers.json index 4944eaed..4ded8e81 100644 --- a/tests/src/test/resources/headers.json +++ b/tests/src/test/resources/headers.json @@ -11,6 +11,26 @@ "body": "with headers" } }, + { + "request": { + "headers": { + "header1": "1" + } + }, + "response": { + "body": "with header 1" + } + }, + { + "request": { + "headers": { + "header2": "2" + } + }, + "response": { + "body": "with header 2" + } + }, { "response": { "body": "no header" From c6fe4921e7f3eb61b4f4a04400d3b7f92209c3ca Mon Sep 17 00:00:00 2001 From: David Blanc Date: Tue, 6 Aug 2019 11:42:30 +0200 Subject: [PATCH 25/32] Correct null and special characters in Custom parser --- .../httpmocker/custom/JsonStringReader.kt | 46 ++++++++----------- .../mappers/JsonStringReaderTest.kt | 19 +++++++- 2 files changed, 37 insertions(+), 28 deletions(-) diff --git a/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonStringReader.kt b/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonStringReader.kt index 0d990a7b..543fb01d 100644 --- a/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonStringReader.kt +++ b/custom-adapter/src/main/kotlin/fr/speekha/httpmocker/custom/JsonStringReader.kt @@ -17,7 +17,6 @@ package fr.speekha.httpmocker.custom import java.util.Locale -import java.util.regex.Pattern /** * A reader object to parse a JSON stream @@ -132,7 +131,7 @@ class JsonStringReader( * Reads a String field value * @return the field value as a String */ - fun readString(): String? = extractNullableStringLiteral() + fun readString(): String? = extractStringLiteral(WRONG_START_OF_STRING_FIELD_ERROR) /** * Reads an object field value @@ -153,7 +152,7 @@ class JsonStringReader( } private fun parseToken( - pattern: Pattern, + pattern: Regex, error: String, converter: (String) -> T ): T { @@ -171,37 +170,30 @@ class JsonStringReader( else -> error(INVALID_BOOLEAN_ERROR) } - private fun extractNullableStringLiteral(): String? = - parseToken(stringPattern, WRONG_START_OF_STRING_FIELD_ERROR) { - val trimmed = it.trim() + private fun extractStringLiteral(error: String = WRONG_START_OF_STRING_ERROR): String? = + parseToken(stringPattern, error) { + val trimmed = it.trim() when { "null" == trimmed -> null - trimmed.startsWith('"') && trimmed.endsWith('"') -> trimmed.drop(1).dropLast(1).replace("\\\"", "\"") - else -> parseError(WRONG_START_OF_STRING_FIELD_ERROR) + trimmed.startsWith('"') && trimmed.endsWith('"') -> trimmed.drop(1).dropLast(1).replace( + "\\\"", + "\"" + ) + else -> parseError(error) } } - private fun extractStringLiteral(): String? { - val start = json.indexOf("\"", index) - val match = Regex("[^\\\\]\"").find(json, start) - val end = match?.range?.endInclusive ?: -1 - if (start < 1 || end == -1 || !isBlank(index, start)) { - parseError(WRONG_START_OF_STRING_ERROR) - } - index = end + 1 - return json.substring(start + 1, end).replace("\\\"", "\"") - } - private fun extractLiteral( - pattern: Pattern, + pattern: Regex, error: String = INVALID_TOKEN_ERROR ): String { - val matcher = pattern.matcher(json.substring(index)) - if (!matcher.find() || !isBlank(index, index + matcher.start())) { + val find = pattern.find(json.substring(index)) + val range = find?.range + if (range == null || !isBlank(index, index + range.first)) { parseError(error) } - index += matcher.end() - return matcher.group() + index += range.last + 1 + return find.value } private fun isFieldSeparator(start: Int, end: Int) = @@ -228,6 +220,6 @@ const val INVALID_NUMBER_ERROR = "Invalid numeric value: " const val INVALID_TOKEN_ERROR = "Invalid token value: " const val INVALID_BOOLEAN_ERROR = "Invalid boolean value: " -private val numericPattern = Pattern.compile("\\d[\\d ]*") -private val alphanumericPattern = Pattern.compile("[^,}\\]\\s]+") -private val stringPattern = Pattern.compile("[^,}\\]]+") +private val numericPattern = Regex("\\d[\\d ]*") +private val alphanumericPattern = Regex("[^,}\\]\\s]+") +private val stringPattern = Regex("(\"((?=\\\\)\\\\(\"|/|\\\\|b|f|n|r|t|u[0-9a-f]{4})|[^\\\\\"]*)*\")|null") diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JsonStringReaderTest.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JsonStringReaderTest.kt index 1deabece..fe21e3d2 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JsonStringReaderTest.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JsonStringReaderTest.kt @@ -116,6 +116,23 @@ class JsonStringReaderTest { assertEquals("""a test "string"""", reader.readString()) } + @Test + fun `should handle null strings`() { + val reader = JsonStringReader("{\"field\": null }") + reader.beginObject() + reader.readFieldName() + assertNull(reader.readString()) + } + + @Test + fun `should read string with special characters`() { + val result = """ { [ \" , } ] """ + val reader = JsonStringReader("{\"field\":\"$result\"}") + reader.beginObject() + reader.readFieldName() + assertEquals(""" { [ " , } ] """, reader.readString()) + } + @ParameterizedTest @MethodSource("stringErrors") fun `should detect error on read string`(input: String, output: String) { @@ -219,7 +236,7 @@ class JsonStringReaderTest { @Test fun `should iterate through list of strings`() { - val json = "[\"1\", \"2\", \"3\"]" + val json = """["1", "2", "3"]""" val list = mutableListOf() with(JsonStringReader(json)) { beginList() From 7ccace1c881d5083c22297d78e8829d9ee5bc154 Mon Sep 17 00:00:00 2001 From: David Blanc Date: Tue, 6 Aug 2019 14:11:14 +0200 Subject: [PATCH 26/32] Refactoring tests for readability --- .../mappers/JsonStringReaderTest.kt | 566 ++++++++++-------- 1 file changed, 310 insertions(+), 256 deletions(-) diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JsonStringReaderTest.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JsonStringReaderTest.kt index fe21e3d2..051755ce 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JsonStringReaderTest.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JsonStringReaderTest.kt @@ -27,7 +27,11 @@ import fr.speekha.httpmocker.custom.WRONG_START_OF_OBJECT_ERROR import fr.speekha.httpmocker.custom.WRONG_START_OF_STRING_FIELD_ERROR import fr.speekha.httpmocker.custom.truncate import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS import org.junit.jupiter.api.assertThrows import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments @@ -36,326 +40,382 @@ import java.util.stream.Stream class JsonStringReaderTest { - @Test - fun `should parse empty string`() { + @Nested + @DisplayName("Given an empty input") + inner class EmptyInput { + val reader = JsonStringReader("") - assertFalse(reader.hasNext()) + @Test + fun `When parsing input, then no new token should be found`() { + assertFalse(reader.hasNext()) + } + + @Test + fun `When reading an object, then an error should occur`() { + val exception = assertThrows { reader.beginObject() } + assertEquals(WRONG_START_OF_OBJECT_ERROR, exception.message) + } + + @Test + fun `When reading a list, then an error should occur`() { + val exception = assertThrows { reader.beginList() } + assertEquals(WRONG_START_OF_LIST_ERROR, exception.message) + } } - @Test - fun `should handle empty object`() { + @Nested + @DisplayName("Given an empty object as input") + inner class EmptyObject { + val reader = JsonStringReader("{}") - assertTrue(reader.hasNext()) - } - @Test - fun `should handle end of string instead of object`() { - val reader = JsonStringReader("") - val exception = assertThrows { reader.beginObject() } - assertEquals(WRONG_START_OF_OBJECT_ERROR, exception.message) - } + @Test + fun `When parsing the object, then input should be valid`() { + assertTrue(reader.hasNext()) + } - @Test - fun `should handle end of string instead of list`() { - val reader = JsonStringReader("") - val exception = assertThrows { reader.beginList() } - assertEquals(WRONG_START_OF_LIST_ERROR, exception.message) + @Test + fun `When parsing as a list, then an error should occur`() { + val exception = assertThrows { reader.beginList() } + assertEquals("$WRONG_START_OF_LIST_ERROR{}", exception.message) + } + + @Test + fun `When parsing the object, then the end of the object should be detected`() { + reader.beginObject() + assertFalse(reader.hasNext()) + } } - @Test - fun `should handle erroneous start of object`() { + @Nested + @DisplayName("Given an empty array as input") + inner class EmptyArray { + val reader = JsonStringReader("[]") - val exception = assertThrows { reader.beginObject() } - assertEquals("$WRONG_START_OF_OBJECT_ERROR[]", exception.message) - } - @Test - fun `should handle erroneous start of list`() { - val reader = JsonStringReader("{}") - val exception = assertThrows { reader.beginList() } - assertEquals("$WRONG_START_OF_LIST_ERROR{}", exception.message) - } + @Test + fun `When parsing as an object, then an error should occur`() { + val exception = assertThrows { reader.beginObject() } + assertEquals("$WRONG_START_OF_OBJECT_ERROR[]", exception.message) + } - @Test - fun `should detect end of object`() { - val reader = JsonStringReader("{}") - reader.beginObject() - assertFalse(reader.hasNext()) + @Test + fun `When parsing the array, then the end of the list should be detected`() { + reader.beginList() + assertFalse(reader.hasNext()) + } } - @Test - fun `should detect end of list`() { - val reader = JsonStringReader("[]") - reader.beginList() - assertFalse(reader.hasNext()) - } + @Nested + @DisplayName("Given an object with a numeric field as input") + inner class NumericField { - @Test - fun `should read field name`() { - val result = "field" - val reader = JsonStringReader("{\"$result\" : \"value\"}") - reader.beginObject() - assertEquals(result, reader.readFieldName()) - } + @Test + fun `When reading the field name, then the proper string should be returned`() { + val result = "field" + val reader = JsonStringReader("{\"$result\" : \"value\"}") + reader.beginObject() + assertEquals(result, reader.readFieldName()) + } - @Test - fun `should read string`() { - val result = "a test string" - val reader = JsonStringReader("{\"field\":\"$result\"}") - reader.beginObject() - reader.readFieldName() - assertEquals(result, reader.readString()) - } + @Test + fun `When field is an integer, then its value should be retrieved`() { + val result = 1152 + val reader = JsonStringReader("{\"field\": 1 152 }") + reader.beginObject() + reader.readFieldName() + assertEquals(result, reader.readInt()) + } - @Test - fun `should read string with quotes`() { - val result = """a test \"string\"""" - val reader = JsonStringReader("{\"field\":\"$result\"}") - reader.beginObject() - reader.readFieldName() - assertEquals("""a test "string"""", reader.readString()) - } + @Test + fun `When reading an incorrect integer, then an error should occur`() { + val reader = JsonStringReader("{\"field\": \"1 152\" }") + reader.beginObject() + reader.readFieldName() + val exception = assertThrows { reader.readInt() } + assertEquals("$INVALID_NUMBER_ERROR \"1 152\" }", exception.message) + } - @Test - fun `should handle null strings`() { - val reader = JsonStringReader("{\"field\": null }") - reader.beginObject() - reader.readFieldName() - assertNull(reader.readString()) + @Test + fun `When field is a long, then its value should be retrieved`() { + val result = 1152L + val reader = JsonStringReader("{\"field\": 1 152 }") + reader.beginObject() + reader.readFieldName() + assertEquals(result, reader.readLong()) + } } - @Test - fun `should read string with special characters`() { - val result = """ { [ \" , } ] """ - val reader = JsonStringReader("{\"field\":\"$result\"}") - reader.beginObject() - reader.readFieldName() - assertEquals(""" { [ " , } ] """, reader.readString()) - } + @Nested + @DisplayName("Given an object with a Boolean field as input") + inner class BooleanField { - @ParameterizedTest - @MethodSource("stringErrors") - fun `should detect error on read string`(input: String, output: String) { - val reader = JsonStringReader(input) - val exception = assertThrows { reader.readString() } - assertEquals(output, exception.message) - } + @Test + fun `When field is a boolean, then its value should be retrieved`() { + val reader = JsonStringReader("{\"field1\": true , \"field2\": false }") + reader.beginObject() + reader.readFieldName() + val field1 = reader.readBoolean() + reader.next() + reader.readFieldName() + val field2 = reader.readBoolean() + assertEquals(true, field1) + assertEquals(false, field2) + } - @Test - fun `should read only int field in object`() { - val result = 1152 - val reader = JsonStringReader("{\"field\": 1 152 }") - reader.beginObject() - reader.readFieldName() - assertEquals(result, reader.readInt()) - } + @Test + fun `When reading an incorrect boolean, then an error should occur`() { + val reader = JsonStringReader("{\"field\": error }") + reader.beginObject() + reader.readFieldName() + val exception = assertThrows { reader.readBoolean() } + assertEquals("$INVALID_BOOLEAN_ERROR error }", exception.message) + } - @Test - fun `should detect error on read int`() { - val reader = JsonStringReader("{\"field\": \"1 152\" }") - reader.beginObject() - reader.readFieldName() - val exception = assertThrows { reader.readInt() } - assertEquals("$INVALID_NUMBER_ERROR \"1 152\" }", exception.message) } - @Test - fun `should read only long field in object`() { - val result = 1152L - val reader = JsonStringReader("{\"field\": 1 152 }") - reader.beginObject() - reader.readFieldName() - assertEquals(result, reader.readLong()) - } - @Test - fun `should read only boolean field in object`() { - val reader = JsonStringReader("{\"field1\": true , \"field2\": false }") - reader.beginObject() - reader.readFieldName() - val field1 = reader.readBoolean() - reader.next() - reader.readFieldName() - val field2 = reader.readBoolean() - assertEquals(true, field1) - assertEquals(false, field2) - } + @TestInstance(PER_CLASS) + @Nested + @DisplayName("Given an object with a String field as input") + inner class StringField { - @Test - fun `should detect error on read boolean`() { - val reader = JsonStringReader("{\"field\": error }") - reader.beginObject() - reader.readFieldName() - val exception = assertThrows { reader.readBoolean() } - assertEquals("$INVALID_BOOLEAN_ERROR error }", exception.message) - } + @Test + fun `When String is simple, its value should be retrieved`() { + val result = "a test string" + val reader = JsonStringReader("{\"field\":\"$result\"}") + reader.beginObject() + reader.readFieldName() + assertEquals(result, reader.readString()) + } - @Test - fun `should iterate through object`() { - val reader = JsonStringReader(simpleObject) - val list = mutableListOf>() - reader.beginObject() - while (reader.hasNext()) { - val field = reader.readFieldName() - val value = reader.readString() - reader.next() - list += field to value + @Test + fun `When String contains quotes, its value should be retrieved`() { + val result = """a test \"string\"""" + val reader = JsonStringReader("{\"field\":\"$result\"}") + reader.beginObject() + reader.readFieldName() + assertEquals("""a test "string"""", reader.readString()) } - assertEquals(listOf("field1" to "1", "field2" to "2"), list) - } - @Test - fun `should detect when object is not entirely processed`() { - with(JsonStringReader(simpleObject)) { - beginObject() - readFieldName() - readString() - next() - val exception = assertThrows { endObject() } - assertEquals( - "$WRONG_END_OF_OBJECT_ERROR\n" + - " \"fie...", exception.message - ) + @Test + fun `When String is null, null should be retrieved`() { + val reader = JsonStringReader("{\"field\": null }") + reader.beginObject() + reader.readFieldName() + assertNull(reader.readString()) } - } - @Test - fun `should iterate through list of integers`() { - val json = "[1, 2, 3]" - val list = mutableListOf() - with(JsonStringReader(json)) { - beginList() - while (hasNext()) { - list += readInt() - next() - } - endList() + @Test + fun `When String contains JSON special characters, its value should be retrieved`() { + val result = """ { [ \" , } ] """ + val reader = JsonStringReader("{\"field\":\"$result\"}") + reader.beginObject() + reader.readFieldName() + assertEquals(""" { [ " , } ] """, reader.readString()) } - assertEquals(listOf(1, 2, 3), list) - } - @Test - fun `should iterate through list of strings`() { - val json = """["1", "2", "3"]""" - val list = mutableListOf() - with(JsonStringReader(json)) { - beginList() - while (hasNext()) { - list += readString() - next() - } - endList() + @ParameterizedTest(name = "Incorrect value: {0}") + @MethodSource("stringErrors") + fun `When String is incorrect, an error should occur`(input: String, output: String) { + val reader = JsonStringReader(input) + val exception = assertThrows { reader.readString() } + assertEquals(output, exception.message) } - assertEquals(listOf("1", "2", "3"), list) + + fun stringErrors(): Stream = listOf( + arrayOf("{a test string}", "$WRONG_START_OF_STRING_FIELD_ERROR{a test..."), + arrayOf("{\"a test string\"}", "$WRONG_START_OF_STRING_FIELD_ERROR{\"a tes...") + ).map { Arguments.of(*it) }.stream() } - @Test - fun `should iterate through list`() { - val list = mutableListOf>() - with(JsonStringReader(simpleList)) { - beginList() - while (hasNext()) { - val map = mutableMapOf() + @Nested + @DisplayName("Given a simple object as input") + inner class SimpleObject { + + private val reader = JsonStringReader(simpleObject) + + @Test + fun `When parsing the object, then fields should be iterable`() { + with(reader) { + val list = mutableListOf>() beginObject() while (hasNext()) { val field = readFieldName() val value = readString() next() - map[field] = value + list += field to value } - endObject() - list += map + assertEquals(listOf("field1" to "1", "field2" to "2"), list) + } + } + + @Test + fun `When finishing the object incorrectly, then an error should occur`() { + with(reader) { + beginObject() + readFieldName() + readString() next() + val exception = assertThrows { endObject() } + assertEquals( + "$WRONG_END_OF_OBJECT_ERROR\n" + + " \"fie...", exception.message + ) } - endList() } - assertEquals( - listOf( - mapOf("field1" to "1", "field2" to "2"), - mapOf("field1" to "1", "field2" to "2") - ), list - ) } - @Test - fun `should detect when list is not entirely processed`() { - with(JsonStringReader(simpleList)) { - beginList() - val map = mutableMapOf() - beginObject() - while (hasNext()) { - val field = readFieldName() - val value = readString() + @Nested + @DisplayName("Given an object with an Object field as input and a corresponding adapter") + inner class ObjectField { + + private val reader = JsonStringReader(complexObject) + + @Test + fun `When reading the field, then the object should be retrieved`() { + with(reader) { + val obj = mutableMapOf() + beginObject() + obj[readFieldName()] = readString() ?: error("Incorrect object name") + next() + obj[readFieldName()] = readObject(mapAdapter) next() - map[field] = value + assertEquals( + mapOf("field0" to "0", "object" to mapOf("field1" to "1", "field2" to "2")), + obj + ) + } + } + + @Test + fun `When object is incorrect, an error should occur`() { + with(JsonStringReader(complexObject)) { + beginObject() + readFieldName() + val exception = assertThrows { + readObject(mapAdapter) + } + assertEquals( + "$WRONG_START_OF_OBJECT_ERROR \"0\",\n" + + " ...", exception.message + ) } - endObject() - val exception = assertThrows { endList() } - assertEquals( - "$WRONG_END_OF_LIST_ERROR,\n" + - " {\n" + - " ...", exception.message - ) } } - @Test - fun `should iterate through list of lists`() { - val json = "[[1, 2, 3],[1, 2, 3]]" - val list = mutableListOf>() - with(JsonStringReader(json)) { - beginList() - while (hasNext()) { - val sublist = mutableListOf() + @Nested + @DisplayName("Given an object with an array field as input") + inner class ArrayField { + + + @Test + fun `should iterate through list of integers`() { + val json = "[1, 2, 3]" + val list = mutableListOf() + with(JsonStringReader(json)) { beginList() while (hasNext()) { - sublist += readInt() + list += readInt() next() } - list += sublist endList() - next() } - endList() + assertEquals(listOf(1, 2, 3), list) } - assertEquals(listOf(listOf(1, 2, 3), listOf(1, 2, 3)), list) - } + @Test + fun `should iterate through list of strings`() { + val json = """["1", "2", "3"]""" + val list = mutableListOf() + with(JsonStringReader(json)) { + beginList() + while (hasNext()) { + list += readString() + next() + } + endList() + } + assertEquals(listOf("1", "2", "3"), list) + } - @Test - fun `should read object field`() { - with(JsonStringReader(complexObject)) { - val obj = mutableMapOf() - beginObject() - obj[readFieldName()] = readString() ?: error("Incorrect object name") - next() - obj[readFieldName()] = readObject(mapAdapter) - next() + @Test + fun `should iterate through list`() { + val list = mutableListOf>() + with(JsonStringReader(simpleList)) { + beginList() + while (hasNext()) { + val map = mutableMapOf() + beginObject() + while (hasNext()) { + val field = readFieldName() + val value = readString() + next() + map[field] = value + } + endObject() + list += map + next() + } + endList() + } assertEquals( - mapOf("field0" to "0", "object" to mapOf("field1" to "1", "field2" to "2")), - obj + listOf( + mapOf("field1" to "1", "field2" to "2"), + mapOf("field1" to "1", "field2" to "2") + ), list ) } - } - @Test - fun `should handle error when read object field`() { - with(JsonStringReader(complexObject)) { - beginObject() - readFieldName() - val exception = assertThrows { - readObject(mapAdapter) + @Test + fun `should detect when list is not entirely processed`() { + with(JsonStringReader(simpleList)) { + beginList() + val map = mutableMapOf() + beginObject() + while (hasNext()) { + val field = readFieldName() + val value = readString() + next() + map[field] = value + } + endObject() + val exception = assertThrows { endList() } + assertEquals( + "$WRONG_END_OF_LIST_ERROR,\n" + + " {\n" + + " ...", exception.message + ) } - assertEquals( - "$WRONG_START_OF_OBJECT_ERROR \"0\",\n" + - " ...", exception.message - ) + } + + @Test + fun `should iterate through list of lists`() { + val json = "[[1, 2, 3],[1, 2, 3]]" + val list = mutableListOf>() + with(JsonStringReader(json)) { + beginList() + while (hasNext()) { + val sublist = mutableListOf() + beginList() + while (hasNext()) { + sublist += readInt() + next() + } + list += sublist + endList() + next() + } + endList() + } + assertEquals(listOf(listOf(1, 2, 3), listOf(1, 2, 3)), list) } } - @ParameterizedTest + @ParameterizedTest(name = "When input is \"{0}\", then result should be \"{1}\"") @MethodSource("truncateData") - fun `should truncate strings properly`(input: String, output: String) { + @DisplayName("Given a String to truncate") + fun truncateTests(input: String, output: String) { assertEquals(output, input.truncate(10)) } @@ -405,12 +465,6 @@ class JsonStringReaderTest { } """.trimIndent() - @JvmStatic - fun stringErrors(): Stream = listOf( - arrayOf("{a test string}", "$WRONG_START_OF_STRING_FIELD_ERROR{a test..."), - arrayOf("{\"a test string\"}", "$WRONG_START_OF_STRING_FIELD_ERROR{\"a tes...") - ).map { Arguments.of(*it) }.stream() - @JvmStatic fun truncateData(): Stream = listOf( arrayOf("", ""), From c16baa0f90d02959c3cb1d619f9c650ec187376b Mon Sep 17 00:00:00 2001 From: David Blanc Date: Tue, 6 Aug 2019 14:26:53 +0200 Subject: [PATCH 27/32] Refactoring tests for readability --- .../mappers/AbstractJsonMapperTest.kt | 102 ++++++++++-------- .../httpmocker/mappers/CustomAdapterTest.kt | 29 +---- .../httpmocker/mappers/GsonMapperTest.kt | 2 + .../httpmocker/mappers/JacksonMapperTest.kt | 2 + .../mappers/JsonFormatConverterTest.kt | 18 ++-- .../mappers/JsonStringReaderTest.kt | 1 + .../httpmocker/mappers/KotlinxMapperTest.kt | 2 + .../httpmocker/mappers/MoshiMapperTest.kt | 2 + 8 files changed, 82 insertions(+), 76 deletions(-) diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/AbstractJsonMapperTest.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/AbstractJsonMapperTest.kt index 0243350e..17ce46d4 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/AbstractJsonMapperTest.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/AbstractJsonMapperTest.kt @@ -21,25 +21,31 @@ import fr.speekha.httpmocker.model.Header import fr.speekha.httpmocker.model.Matcher import fr.speekha.httpmocker.model.ResponseDescriptor import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test abstract class AbstractJsonMapperTest(val mapper: Mapper) { - @Test - fun `should parse a JSON file`() { - val result = mapper.readMatches(getCompleteInput()) - assertEquals(completeData, result) - } + @Nested + @DisplayName("Given a JSON stream to parse") + inner class ParseJson { - @Test - fun `should populate default values properly`() { - val result = mapper.readMatches(getPartialInput()) - assertEquals(partialData, result) - } + @Test + fun `When input is a comprehensive file, then a fully populated object should be returned`() { + val result = mapper.readMatches(getCompleteInput()) + assertEquals(completeData, result) + } - @Test - fun `should handle headers with colons`() { - val json = """[ + @Test + fun `When input is a partial scenario, then default values should be used`() { + val result = mapper.readMatches(getPartialInput()) + assertEquals(partialData, result) + } + + @Test + fun `When headers contain colons, then their value should be properly parsed`() { + val json = """[ { "response": { "headers": { @@ -49,22 +55,22 @@ abstract class AbstractJsonMapperTest(val mapper: Mapper) { } ]""" - assertEquals( - listOf( - Matcher( - response = ResponseDescriptor( - headers = listOf( - Header("Location", "http://www.google.com") + assertEquals( + listOf( + Matcher( + response = ResponseDescriptor( + headers = listOf( + Header("Location", "http://www.google.com") + ) ) ) - ) - ), mapper.readMatches(json.byteInputStream()) - ) - } + ), mapper.readMatches(json.byteInputStream()) + ) + } - @Test - fun `should handle headers with quotes`() { - val json = """[ + @Test + fun `When headers contain quotes, then their value should be properly parsed`() { + val json = """[ { "response": { "headers": { @@ -74,28 +80,36 @@ abstract class AbstractJsonMapperTest(val mapper: Mapper) { } ]""" - assertEquals( - listOf( - Matcher( - response = ResponseDescriptor( - headers = listOf( - Header("Set-Cookie", "\"cookie\"=\"value\"") + assertEquals( + listOf( + Matcher( + response = ResponseDescriptor( + headers = listOf( + Header("Set-Cookie", "\"cookie\"=\"value\"") + ) ) ) - ) - ), mapper.readMatches(json.byteInputStream()) - ) - } + ), mapper.readMatches(json.byteInputStream()) + ) + } - @Test - fun `should write a proper JSON file`() { - val expected = getExpectedOutput() - testStream(expected, mapper.serialize(listOf(completeData[0]))) } - @Test - fun `should write a proper minimum JSON file`() { - val expected = getMinimalOutput() - testStream(expected, mapper.serialize(listOf(Matcher(response = ResponseDescriptor())))) + @Nested + @DisplayName("Given a scenario to write") + inner class WriteJson { + + @Test + fun `When input is minimal, then null fields should be omitted`() { + val expected = getMinimalOutput() + testStream(expected, mapper.serialize(listOf(Matcher(response = ResponseDescriptor())))) + } + + @Test + fun `When input is a complete object, the all fields should be properly written`() { + val expected = getExpectedOutput() + testStream(expected, mapper.serialize(listOf(completeData[0]))) + } + } } \ No newline at end of file diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/CustomAdapterTest.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/CustomAdapterTest.kt index aa723360..06f6beab 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/CustomAdapterTest.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/CustomAdapterTest.kt @@ -17,30 +17,7 @@ package fr.speekha.httpmocker.mappers import fr.speekha.httpmocker.custom.CustomMapper -import fr.speekha.httpmocker.model.Matcher -import fr.speekha.httpmocker.model.RequestDescriptor -import fr.speekha.httpmocker.model.ResponseDescriptor -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test +import org.junit.jupiter.api.DisplayName -class CustomAdapterTest : AbstractJsonMapperTest(CustomMapper()) { - @Test - fun `step by step`() { - val json = """[ - { - "request": {}, - "response": {} - }, - { - "response": {} - } -]""" - val mapper = CustomMapper() - assertEquals( - listOf( - Matcher(response = ResponseDescriptor()), - Matcher(RequestDescriptor(), ResponseDescriptor()) - ), mapper.readMatches(json.byteInputStream()) - ) - } -} \ No newline at end of file +@DisplayName("Custom Adapter") +class CustomAdapterTest : AbstractJsonMapperTest(CustomMapper()) \ No newline at end of file diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/GsonMapperTest.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/GsonMapperTest.kt index 80b07f41..490801c3 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/GsonMapperTest.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/GsonMapperTest.kt @@ -17,5 +17,7 @@ package fr.speekha.httpmocker.mappers import fr.speekha.httpmocker.gson.GsonMapper +import org.junit.jupiter.api.DisplayName +@DisplayName("GSON Adapter") class GsonMapperTest : AbstractJsonMapperTest(GsonMapper()) diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JacksonMapperTest.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JacksonMapperTest.kt index 5235b6f6..79bdbdbf 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JacksonMapperTest.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JacksonMapperTest.kt @@ -17,5 +17,7 @@ package fr.speekha.httpmocker.mappers import fr.speekha.httpmocker.jackson.JacksonMapper +import org.junit.jupiter.api.DisplayName +@DisplayName("Jackson Adapter") class JacksonMapperTest : AbstractJsonMapperTest(JacksonMapper()) \ No newline at end of file diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JsonFormatConverterTest.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JsonFormatConverterTest.kt index a622bba2..8df656c6 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JsonFormatConverterTest.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JsonFormatConverterTest.kt @@ -18,22 +18,26 @@ package fr.speekha.httpmocker.mappers import fr.speekha.httpmocker.kotlinx.JsonFormatConverter import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource import java.util.stream.Stream +@DisplayName("JSON format conversion") class JsonFormatConverterTest { - @ParameterizedTest(name = "{0}") + @ParameterizedTest(name = "When input {0}, then output should be properly formatted") @MethodSource("dataImport") - fun `should format JSON properly for reading`(title: String, input: String, output: String) { + @DisplayName("Given a proper JSON stream to read") + fun readConversionTest(title: String, input: String, output: String) { assertEquals(output, JsonFormatConverter().import(input)) } - @ParameterizedTest(name = "{0}") + @ParameterizedTest(name = "When input {0}, then output should be properly formatted") @MethodSource("dataExport") - fun `should format JSON properly for writing`(title: String, input: String, output: String) { + @DisplayName("Given a proper JSON stream to write") + fun writeConversionTest(title: String, input: String, output: String) { assertEquals(output, JsonFormatConverter().export(input)) } @@ -47,10 +51,12 @@ class JsonFormatConverterTest { @JvmStatic fun dataExport(): Stream = testTitles.zip( - kotlinxFormat zip commonFormat) { a, (b, c) -> Arguments.of(a, b, c) } + kotlinxFormat zip commonFormat + ) { a, (b, c) -> Arguments.of(a, b, c) } .stream() - private val testTitles = listOf("Minimal JSON", "Empty header list", "One header", "Duplicate headers") + private val testTitles = + listOf("is a minimal JSON", "contains an empty header list", "contains one header", "contains duplicate headers") private val commonFormat = listOf( """ diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JsonStringReaderTest.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JsonStringReaderTest.kt index 051755ce..df23d35e 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JsonStringReaderTest.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JsonStringReaderTest.kt @@ -38,6 +38,7 @@ import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource import java.util.stream.Stream +@DisplayName("Custom JSON parser") class JsonStringReaderTest { @Nested diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/KotlinxMapperTest.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/KotlinxMapperTest.kt index 96e7e32b..6c118001 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/KotlinxMapperTest.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/KotlinxMapperTest.kt @@ -18,6 +18,8 @@ package fr.speekha.httpmocker.mappers import fr.speekha.httpmocker.kotlinx.JsonFormatConverter import fr.speekha.httpmocker.kotlinx.KotlinxMapper +import org.junit.jupiter.api.DisplayName +@DisplayName("Kotlinx serialization Adapter") class KotlinxMapperTest : AbstractJsonMapperTest(KotlinxMapper(JsonFormatConverter()::import, JsonFormatConverter()::export)) \ No newline at end of file diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/MoshiMapperTest.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/MoshiMapperTest.kt index 4782e889..d21a1584 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/MoshiMapperTest.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/MoshiMapperTest.kt @@ -17,5 +17,7 @@ package fr.speekha.httpmocker.mappers import fr.speekha.httpmocker.moshi.MoshiMapper +import org.junit.jupiter.api.DisplayName +@DisplayName("Moshi Adapter") class MoshiMapperTest : AbstractJsonMapperTest(MoshiMapper()) \ No newline at end of file From 38b537260b6277ac4e80d1784ad2c412fe4f9d4a Mon Sep 17 00:00:00 2001 From: David Blanc Date: Tue, 6 Aug 2019 18:22:40 +0200 Subject: [PATCH 28/32] Refactoring tests for readability --- .../policies/MirrorPathPolicyTest.kt | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/policies/MirrorPathPolicyTest.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/policies/MirrorPathPolicyTest.kt index b8c2ce0f..64a8aa7c 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/policies/MirrorPathPolicyTest.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/policies/MirrorPathPolicyTest.kt @@ -18,25 +18,40 @@ package fr.speekha.httpmocker.policies import fr.speekha.httpmocker.buildRequest import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test +@DisplayName("MirrorPathPolicy") class MirrorPathPolicyTest { val policy: FilingPolicy = MirrorPathPolicy() - @Test - fun `should keep the same path as the URL`() { - val request = buildRequest( - "http://www.somestuff.com/test/with/path", listOf("header" to "value"), "POST", "body" - ) - assertEquals("test/with/path.json", policy.getPath(request)) - } + @Nested + @DisplayName("Given a path mirroring policy") + inner class ParseJson { + @Test + @DisplayName("When processing a URL, then file path should be kept from the URL") + fun keepUrl() { + val request = buildRequest( + "http://www.somestuff.com/test/with/path", + listOf("header" to "value"), + "POST", + "body" + ) + assertEquals("test/with/path.json", policy.getPath(request)) + } - @Test - fun `should handle URL when last segment is empty`() { - val request = buildRequest( - "http://www.somestuff.com/test/with/path/", listOf("header" to "value"), "POST", "body" - ) - assertEquals("test/with/path/index.json", policy.getPath(request)) + @Test + @DisplayName("When processing an URL ending with a '/', then index.json should be added in the last empty segment") + fun emptyFolder() { + val request = buildRequest( + "http://www.somestuff.com/test/with/path/", + listOf("header" to "value"), + "POST", + "body" + ) + assertEquals("test/with/path/index.json", policy.getPath(request)) + } } } \ No newline at end of file From 774f73d45032bcf283e5af3a703c39ed87be2d2a Mon Sep 17 00:00:00 2001 From: David Blanc Date: Wed, 7 Aug 2019 09:31:47 +0200 Subject: [PATCH 29/32] Remove anonymous classes in Builder --- .../httpmocker/MockResponseInterceptor.kt | 39 +++++++++++-------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/mocker/src/main/kotlin/fr/speekha/httpmocker/MockResponseInterceptor.kt b/mocker/src/main/kotlin/fr/speekha/httpmocker/MockResponseInterceptor.kt index 25433ada..cbef981c 100644 --- a/mocker/src/main/kotlin/fr/speekha/httpmocker/MockResponseInterceptor.kt +++ b/mocker/src/main/kotlin/fr/speekha/httpmocker/MockResponseInterceptor.kt @@ -185,7 +185,7 @@ private constructor( * on the request being intercepted * @param policy the naming policy to use for scenario files */ - fun decodeScenarioPathWith(policy: FilingPolicy) = apply { + fun decodeScenarioPathWith(policy: FilingPolicy): Builder = apply { filingPolicy = policy } @@ -194,10 +194,12 @@ private constructor( * on the request being intercepted * @param policy a lambda to use as the naming policy for scenario files */ - fun decodeScenarioPathWith(policy: (Request) -> String) = apply { - filingPolicy = object : FilingPolicy { - override fun getPath(request: Request): String = policy(request) - } + fun decodeScenarioPathWith(policy: (Request) -> String): Builder = apply { + filingPolicy = FilingPolicyBuilder(policy) + } + + private class FilingPolicyBuilder(private val policy: (Request) -> String) : FilingPolicy { + override fun getPath(request: Request): String = policy(request) } /** @@ -205,7 +207,7 @@ private constructor( * @param loading a function to load files by name and path as a stream (could use * Android's assets.open, Classloader.getRessourceAsStream, FileInputStream, etc.) */ - fun loadFileWith(loading: LoadFile) = apply { + fun loadFileWith(loading: LoadFile): Builder = apply { openFile = loading } @@ -213,7 +215,7 @@ private constructor( * Uses dynamic mocks to answer network requests instead of file scenarios * @param callback A callback to invoke when a request in intercepted */ - fun useDynamicMocks(callback: RequestCallback) = apply { + fun useDynamicMocks(callback: RequestCallback): Builder = apply { dynamicCallbacks += callback } @@ -223,17 +225,20 @@ private constructor( * ResponseDescriptor for the current Request or null if not suitable Response could be * computed */ - fun useDynamicMocks(callback: (Request) -> ResponseDescriptor?) = - useDynamicMocks(object : RequestCallback { - override fun loadResponse(request: Request): ResponseDescriptor? = - callback(request) - }) + fun useDynamicMocks(callback: (Request) -> ResponseDescriptor?): Builder = + useDynamicMocks(CallBackBuilder(callback)) + + private class CallBackBuilder( + private val block: (Request) -> ResponseDescriptor? + ) : RequestCallback { + override fun loadResponse(request: Request): ResponseDescriptor? = block(request) + } /** * Defines the mapper to use to parse the scenario files (Jackson, Moshi, GSON...) * @param objectMapper A Mapper to parse scenario files. */ - fun parseScenariosWith(objectMapper: Mapper) = apply { + fun parseScenariosWith(objectMapper: Mapper): Builder = apply { mapper = objectMapper } @@ -241,7 +246,7 @@ private constructor( * Defines the folder where scenarios should be stored when recording * @param folder the root folder where saved scenarios should be saved */ - fun saveScenariosIn(folder: File) = apply { + fun saveScenariosIn(folder: File): Builder = apply { root = folder } @@ -250,7 +255,7 @@ private constructor( * @param failOnError if true, failure to save scenarios will throw an exception. * If false, saving exceptions will be ignored. */ - fun failOnRecordingError(failOnError: Boolean) = apply { + fun failOnRecordingError(failOnError: Boolean): Builder = apply { showSavingErrors = failOnError } @@ -260,7 +265,7 @@ private constructor( * animations during your network calls). * @param delay default pause delay for network responses in ms */ - fun addFakeNetworkDelay(delay: Long) = apply { + fun addFakeNetworkDelay(delay: Long): Builder = apply { simulatedDelay = delay } @@ -269,7 +274,7 @@ private constructor( * requests...) * @param status The interceptor mode */ - fun setInterceptorStatus(status: Mode) = apply { + fun setInterceptorStatus(status: Mode): Builder = apply { interceptorMode = status } From 6ba13b6d5847013ce2a402b082e311eae148df3d Mon Sep 17 00:00:00 2001 From: David Blanc Date: Wed, 7 Aug 2019 08:29:46 +0200 Subject: [PATCH 30/32] Refactoring tests for readability --- .../httpmocker/policies/SingleFolderPolicy.kt | 2 + .../httpmocker/policies/InMemoryPolicyTest.kt | 180 ++++++++++-------- .../policies/MirrorPathPolicyTest.kt | 4 +- .../policies/ServerSpecificPolicyTest.kt | 42 ++-- .../policies/SingleFilePolicyTest.kt | 28 ++- .../policies/SingleFolderPolicyTest.kt | 80 +++++--- 6 files changed, 204 insertions(+), 132 deletions(-) diff --git a/mocker/src/main/kotlin/fr/speekha/httpmocker/policies/SingleFolderPolicy.kt b/mocker/src/main/kotlin/fr/speekha/httpmocker/policies/SingleFolderPolicy.kt index 4c7f219c..595a264b 100644 --- a/mocker/src/main/kotlin/fr/speekha/httpmocker/policies/SingleFolderPolicy.kt +++ b/mocker/src/main/kotlin/fr/speekha/httpmocker/policies/SingleFolderPolicy.kt @@ -30,6 +30,8 @@ class SingleFolderPolicy(private val rootFolder: String = "") : FilingPolicy { .pathSegments() .filter { it.isNotEmpty() } .joinToString("_") + .takeIf { it.isNotBlank() } + ?: "index" return "$prefix$fileName.json" } diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/policies/InMemoryPolicyTest.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/policies/InMemoryPolicyTest.kt index f30d5d4e..0b7a6a35 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/policies/InMemoryPolicyTest.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/policies/InMemoryPolicyTest.kt @@ -25,110 +25,122 @@ import fr.speekha.httpmocker.model.RequestDescriptor import fr.speekha.httpmocker.model.ResponseDescriptor import okhttp3.OkHttpClient import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test +@DisplayName("InMemoryPolicy") class InMemoryPolicyTest { private val mapper = JacksonMapper() private val policy = InMemoryPolicy(mapper) - private val interceptor = MockResponseInterceptor.Builder() - .decodeScenarioPathWith(policy) - .loadFileWith(policy::matchRequest) - .parseScenariosWith(mapper) - .setInterceptorStatus(ENABLED) - .build() - - @Test - fun `should return URL as path`() { - val url = "http://www.test.fr/path?param=1" - assertEquals(url, policy.getPath(buildRequest(url))) - } - - @Test - fun `should allow to retrieve a scenario based on a URL`() { - val url = "http://www.test.fr/path1?param=1" - policy.addMatcher( - url, Matcher( - RequestDescriptor(method = "GET"), - ResponseDescriptor( - code = 200, - body = "get some body", - mediaType = "text/plain" + @Nested + @DisplayName("Given an in memory policy") + inner class TestPolicy { + + private val interceptor = MockResponseInterceptor.Builder() + .decodeScenarioPathWith(policy) + .loadFileWith(policy::matchRequest) + .parseScenariosWith(mapper) + .setInterceptorStatus(ENABLED) + .build() + + @Test + @DisplayName("When processing a URL, then resulting path should be the input URL") + fun `should return URL as path`() { + val url = "http://www.test.fr/path?param=1" + assertEquals(url, policy.getPath(buildRequest(url))) + } + + @Test + @DisplayName("When processing a request, then an existing match should be found") + fun `should allow to retrieve a scenario based on a URL`() { + val url = "http://www.test.fr/path1?param=1" + policy.addMatcher( + url, Matcher( + RequestDescriptor(method = "GET"), + ResponseDescriptor( + code = 200, + body = "get some body", + mediaType = "text/plain" + ) ) ) - ) - - val client = OkHttpClient.Builder().addInterceptor(interceptor).build() - val getResponse = client.newCall(buildRequest(url, method = "GET")).execute() - - assertEquals(200, getResponse.code()) - assertEquals("get some body", getResponse.body()?.string()) - } - @Test - fun `should allow to add several matchers for the same URL`() { - val url = "http://www.test.fr/path1?param=1" - policy.addMatcher( - url, Matcher( - RequestDescriptor(method = "GET"), - ResponseDescriptor( - code = 200, - body = "get some body", - mediaType = "text/plain" + val client = OkHttpClient.Builder().addInterceptor(interceptor).build() + val getResponse = client.newCall(buildRequest(url, method = "GET")).execute() + + assertEquals(200, getResponse.code()) + assertEquals("get some body", getResponse.body()?.string()) + } + + @Test + @DisplayName("When processing a request with several possible matches, then all matching entries should be returned") + fun `should allow to add several matchers for the same URL`() { + val url = "http://www.test.fr/path1?param=1" + policy.addMatcher( + url, Matcher( + RequestDescriptor(method = "GET"), + ResponseDescriptor( + code = 200, + body = "get some body", + mediaType = "text/plain" + ) ) ) - ) - policy.addMatcher( - url, Matcher( - RequestDescriptor(method = "POST"), - ResponseDescriptor( - code = 200, - body = "post some body", - mediaType = "text/plain" + policy.addMatcher( + url, Matcher( + RequestDescriptor(method = "POST"), + ResponseDescriptor( + code = 200, + body = "post some body", + mediaType = "text/plain" + ) ) ) - ) - - val client = OkHttpClient.Builder().addInterceptor(interceptor).build() - val getResponse = client.newCall(buildRequest(url, listOf(), "GET")).execute() - val postResponse = client.newCall(buildRequest(url, listOf(), "POST", "body")).execute() - - assertEquals("get some body", getResponse.body()?.string()) - assertEquals("post some body", postResponse.body()?.string()) - } - @Test - fun `should allow to add matchers for different URLs`() { - val url1 = "http://www.test.fr/path1?param=1" - val url2 = "http://www.test.fr/path2?param=1" - policy.addMatcher( - url1, Matcher( - RequestDescriptor(method = "GET"), - ResponseDescriptor( - code = 200, - body = "first body", - mediaType = "text/plain" + val client = OkHttpClient.Builder().addInterceptor(interceptor).build() + val getResponse = client.newCall(buildRequest(url, listOf(), "GET")).execute() + val postResponse = client.newCall(buildRequest(url, listOf(), "POST", "body")).execute() + + assertEquals("get some body", getResponse.body()?.string()) + assertEquals("post some body", postResponse.body()?.string()) + } + + @Test + @DisplayName("When processing requests with different URLs, then corresponding match should be returned") + fun `should allow to add matchers for different URLs`() { + val url1 = "http://www.test.fr/path1?param=1" + val url2 = "http://www.test.fr/path2?param=1" + policy.addMatcher( + url1, Matcher( + RequestDescriptor(method = "GET"), + ResponseDescriptor( + code = 200, + body = "first body", + mediaType = "text/plain" + ) ) ) - ) - policy.addMatcher( - url2, Matcher( - RequestDescriptor(method = "GET"), - ResponseDescriptor( - code = 200, - body = "second body", - mediaType = "text/plain" + policy.addMatcher( + url2, Matcher( + RequestDescriptor(method = "GET"), + ResponseDescriptor( + code = 200, + body = "second body", + mediaType = "text/plain" + ) ) ) - ) - val client = OkHttpClient.Builder().addInterceptor(interceptor).build() - val response1 = client.newCall(buildRequest(url1, listOf(), "GET")).execute() - val response2 = client.newCall(buildRequest(url2, listOf(), "GET")).execute() + val client = OkHttpClient.Builder().addInterceptor(interceptor).build() + val response1 = client.newCall(buildRequest(url1, listOf(), "GET")).execute() + val response2 = client.newCall(buildRequest(url2, listOf(), "GET")).execute() - assertEquals("first body", response1.body()?.string()) - assertEquals("second body", response2.body()?.string()) + assertEquals("first body", response1.body()?.string()) + assertEquals("second body", response2.body()?.string()) + } } -} \ No newline at end of file +} diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/policies/MirrorPathPolicyTest.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/policies/MirrorPathPolicyTest.kt index 64a8aa7c..70a93b3b 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/policies/MirrorPathPolicyTest.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/policies/MirrorPathPolicyTest.kt @@ -29,7 +29,7 @@ class MirrorPathPolicyTest { @Nested @DisplayName("Given a path mirroring policy") - inner class ParseJson { + inner class TestPolicy { @Test @DisplayName("When processing a URL, then file path should be kept from the URL") fun keepUrl() { @@ -43,7 +43,7 @@ class MirrorPathPolicyTest { } @Test - @DisplayName("When processing an URL ending with a '/', then index.json should be added in the last empty segment") + @DisplayName("When processing a URL ending with a '/', then index.json should be added in the last empty segment") fun emptyFolder() { val request = buildRequest( "http://www.somestuff.com/test/with/path/", diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/policies/ServerSpecificPolicyTest.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/policies/ServerSpecificPolicyTest.kt index 961cdae1..598d3d15 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/policies/ServerSpecificPolicyTest.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/policies/ServerSpecificPolicyTest.kt @@ -18,26 +18,42 @@ package fr.speekha.httpmocker.policies import fr.speekha.httpmocker.buildRequest import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test +@DisplayName("ServerSpecificPolicy") class ServerSpecificPolicyTest { private val policy: FilingPolicy = ServerSpecificPolicy() - @Test - fun `should include URL host in path`() { - val request = buildRequest( - "http://www.somestuff.com/test/with/path", listOf("header" to "value"), "POST", "body" - ) - assertEquals("www.somestuff.com/test/with/path.json", policy.getPath(request)) - } + @Nested + @DisplayName("Given a server specific policy") + inner class TestPolicy { + + @Test + @DisplayName("When processing a URL, host should be present in final path") + fun `should include URL host in path`() { + val request = buildRequest( + "http://www.somestuff.com/test/with/path", + listOf("header" to "value"), + "POST", + "body" + ) + assertEquals("www.somestuff.com/test/with/path.json", policy.getPath(request)) + } - @Test - fun `should handle URL when last segment is empty`() { - val request = buildRequest( - "http://www.somestuff.com/test/with/path/", listOf("header" to "value"), "POST", "body" - ) - assertEquals("www.somestuff.com/test/with/path/index.json", policy.getPath(request)) + @Test + @DisplayName("When processing a URL ending with a '/', then index.json should be added in the last empty segment") + fun `should handle URL when last segment is empty`() { + val request = buildRequest( + "http://www.somestuff.com/test/with/path/", + listOf("header" to "value"), + "POST", + "body" + ) + assertEquals("www.somestuff.com/test/with/path/index.json", policy.getPath(request)) + } } } \ No newline at end of file diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/policies/SingleFilePolicyTest.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/policies/SingleFilePolicyTest.kt index 26a0b1b2..cf0fc3a1 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/policies/SingleFilePolicyTest.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/policies/SingleFilePolicyTest.kt @@ -18,17 +18,29 @@ package fr.speekha.httpmocker.policies import fr.speekha.httpmocker.buildRequest import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test +@DisplayName("SingleFilePolicy") class SingleFilePolicyTest { - @Test - fun `should always return the same file`() { - val file = "folder/singleFile.json" - val policy: FilingPolicy = SingleFilePolicy(file) - val request = buildRequest( - "http://www.somestuff.com/test/with/path", listOf("header" to "value"), "POST", "body" - ) - Assertions.assertEquals(file, policy.getPath(request)) + @Nested + @DisplayName("Given a path mirroring policy") + inner class TestPolicy { + + @Test + @DisplayName("When processing a URL, then file path should always be the same") + fun `should always return the same file`() { + val file = "folder/singleFile.json" + val policy: FilingPolicy = SingleFilePolicy(file) + val request = buildRequest( + "http://www.somestuff.com/test/with/path", + listOf("header" to "value"), + "POST", + "body" + ) + Assertions.assertEquals(file, policy.getPath(request)) + } } } \ No newline at end of file diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/policies/SingleFolderPolicyTest.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/policies/SingleFolderPolicyTest.kt index ac818638..aeac562b 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/policies/SingleFolderPolicyTest.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/policies/SingleFolderPolicyTest.kt @@ -18,37 +18,67 @@ package fr.speekha.httpmocker.policies import fr.speekha.httpmocker.buildRequest import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test +@DisplayName("SingleFolderPolicy") class SingleFolderPolicyTest { + @Nested + @DisplayName("Given a path mirroring policy") + inner class TestPolicy { - @Test - fun `should store configuration files in a single folder`() { - val policy: FilingPolicy = SingleFolderPolicy("folder") - val request = buildRequest( - "http://www.somestuff.com/test/with/path", listOf("header" to "value"), "POST", "body" - ) - Assertions.assertEquals("folder/test_with_path.json", policy.getPath(request)) - } + @Test + @DisplayName("When processing a URL, then resulting file should be in the predefined folder and filename should match URL path") + fun `should store configuration files in a single folder`() { + val policy: FilingPolicy = SingleFolderPolicy("folder") + val request = buildRequest( + "http://www.somestuff.com/test/with/path", + listOf("header" to "value"), + "POST", + "body" + ) + Assertions.assertEquals("folder/test_with_path.json", policy.getPath(request)) + } - @Test - fun `should handle empty root folder`() { - val policy: FilingPolicy = SingleFolderPolicy("") - val request = buildRequest( - "http://www.somestuff.com/test/with/path", listOf("header" to "value"), "POST", "body" - ) - Assertions.assertEquals("test_with_path.json", policy.getPath(request)) - } - - @Test - fun `should handle empty path segments`() { - val policy: FilingPolicy = SingleFolderPolicy("folder") - val request = buildRequest( - "http://www.somestuff.com/test/with/path/", listOf("header" to "value"), "POST", "body" - ) - Assertions.assertEquals("folder/test_with_path.json", policy.getPath(request)) - } + @Test + @DisplayName("When configured folder is empty, then resulting path should only contain a file name") + fun `should handle empty root folder`() { + val policy: FilingPolicy = SingleFolderPolicy("") + val request = buildRequest( + "http://www.somestuff.com/test/with/path", + listOf("header" to "value"), + "POST", + "body" + ) + Assertions.assertEquals("test_with_path.json", policy.getPath(request)) + } + @Test + @DisplayName("When processing a URL ending with a '/', then file name should be based on the URL path") + fun `should handle empty path segments`() { + val policy: FilingPolicy = SingleFolderPolicy("folder") + val request = buildRequest( + "http://www.somestuff.com/test/with/path/", + listOf("header" to "value"), + "POST", + "body" + ) + Assertions.assertEquals("folder/test_with_path.json", policy.getPath(request)) + } + @Test + @DisplayName("When processing a URL with an empty path, then file name should be index.json") + fun `should handle empty path URL`() { + val policy: FilingPolicy = SingleFolderPolicy("folder") + val request = buildRequest( + "http://www.somestuff.com/", + listOf("header" to "value"), + "POST", + "body" + ) + Assertions.assertEquals("folder/index.json", policy.getPath(request)) + } + } } \ No newline at end of file From 5eab752e6d4507c2cc166482c46d7f5fa91eb35f Mon Sep 17 00:00:00 2001 From: David Blanc Date: Wed, 7 Aug 2019 16:47:53 +0200 Subject: [PATCH 31/32] Refactoring tests for readability --- .../httpmocker/jackson/JacksonMapper.kt | 5 +- .../interceptor/DynamicMockTests.kt | 196 ++++---- .../httpmocker/interceptor/RecordTests.kt | 442 ++++++++++-------- .../httpmocker/interceptor/StaticMockTests.kt | 76 ++- .../httpmocker/interceptor/TestWithServer.kt | 28 +- .../httpmocker/policies/InMemoryPolicyTest.kt | 2 +- .../policies/MirrorPathPolicyTest.kt | 2 +- .../policies/ServerSpecificPolicyTest.kt | 2 +- .../policies/SingleFilePolicyTest.kt | 2 +- .../policies/SingleFolderPolicyTest.kt | 2 +- 10 files changed, 403 insertions(+), 354 deletions(-) diff --git a/jackson-adapter/src/main/kotlin/fr/speekha/httpmocker/jackson/JacksonMapper.kt b/jackson-adapter/src/main/kotlin/fr/speekha/httpmocker/jackson/JacksonMapper.kt index 7c1883a9..2f9d27dd 100644 --- a/jackson-adapter/src/main/kotlin/fr/speekha/httpmocker/jackson/JacksonMapper.kt +++ b/jackson-adapter/src/main/kotlin/fr/speekha/httpmocker/jackson/JacksonMapper.kt @@ -38,7 +38,8 @@ import fr.speekha.httpmocker.jackson.ResponseDescriptor as JsonResponseDescripto class JacksonMapper : Mapper { private val mapper: ObjectMapper = - jacksonObjectMapper().setDefaultPropertyInclusion(JsonInclude.Include.NON_ABSENT) + jacksonObjectMapper() + .setDefaultPropertyInclusion(JsonInclude.Include.NON_ABSENT) override fun deserialize(payload: String): List = mapper.readValue>(payload, jacksonTypeRef>()) @@ -52,7 +53,7 @@ class JacksonMapper : Mapper { .map { it.toModel() } override fun writeValue(outputStream: OutputStream, matchers: List) = - mapper.writeValue(outputStream, matchers.map { it.fromModel() }) + mapper.writerWithDefaultPrettyPrinter().writeValue(outputStream, matchers.map { it.fromModel() }) } private fun Matcher.fromModel() = JsonMatcher(request.fromModel(), response.fromModel()) diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/DynamicMockTests.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/DynamicMockTests.kt index 8708ff15..1ce112d2 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/DynamicMockTests.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/DynamicMockTests.kt @@ -17,138 +17,138 @@ package fr.speekha.httpmocker.interceptor import fr.speekha.httpmocker.MockResponseInterceptor +import fr.speekha.httpmocker.MockResponseInterceptor.Mode.DISABLED import fr.speekha.httpmocker.MockResponseInterceptor.Mode.ENABLED -import fr.speekha.httpmocker.MockResponseInterceptor.Mode.RECORD -import fr.speekha.httpmocker.NO_RECORDER_ERROR -import fr.speekha.httpmocker.NO_ROOT_FOLDER_ERROR import fr.speekha.httpmocker.buildRequest import fr.speekha.httpmocker.model.ResponseDescriptor import fr.speekha.httpmocker.scenario.RequestCallback import okhttp3.OkHttpClient import okhttp3.Request import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows -class DynamicMockTests { +@DisplayName("Dynamic Mocks") +class DynamicMockTests : TestWithServer() { - private lateinit var interceptor: MockResponseInterceptor + @Nested + @DisplayName("Given an mock interceptor") + inner class DynamicTests { - private lateinit var client: OkHttpClient + @Test + @DisplayName("When interceptor is disabled, then it should not interfere with requests") + fun `should not interfere with requests when disabled`() { + setupProvider(DISABLED) { null } + enqueueServerResponse(200, "body") - @Test - fun `should reply with a dynamically generated response`() { - setupProvider { - ResponseDescriptor(code = 202, body = "some random body") - } - val response = client.newCall( - buildRequest( - url, - method = "GET" - ) - ).execute() - - assertEquals(202, response.code()) - assertEquals("some random body", response.body()?.string()) - } + val response = executeGetRequest("") - @Test - fun `should reply with a stateful callback`() { - val body = "Time: ${System.currentTimeMillis()}" - val callback = object : RequestCallback { - override fun loadResponse(request: Request) = - ResponseDescriptor(code = 202, body = body) + assertResponseCode(response, 200, "OK") + assertEquals("body", response.body()?.string()) } - setupProvider(callback) - - val response = client.newCall( - buildRequest( - url, - method = "GET" - ) - ).execute() - assertEquals(202, response.code()) - assertEquals(body, response.body()?.string()) - } - - @Test - fun `should support multiple callbacks`() { - val result1 = "First mock" - val result2 = "Second mock" - - interceptor = MockResponseInterceptor.Builder() - .useDynamicMocks { - if (it.url().toString().contains("1")) - ResponseDescriptor(body = result1) - else null - }.useDynamicMocks { - ResponseDescriptor(body = result2) + @Test + @DisplayName("When a lambda is provided, then it should be used to answer requests") + fun `should reply with a dynamically generated response`() { + setupProvider { + ResponseDescriptor(code = 202, body = "some random body") } - .setInterceptorStatus(ENABLED) - .build() - - client = OkHttpClient.Builder().addInterceptor(interceptor).build() - - val response1 = - client.newCall( + val response = client.newCall( buildRequest( - "http://www.test.fr/request1", + url, method = "GET" ) ).execute() - val response2 = - client.newCall( + + assertEquals(202, response.code()) + assertEquals("some random body", response.body()?.string()) + } + + @Test + @DisplayName("When a stateful callback is provided, then it should be used to answer requests") + fun `should reply with a stateful callback`() { + val body = "Time: ${System.currentTimeMillis()}" + val callback = object : RequestCallback { + override fun loadResponse(request: Request) = + ResponseDescriptor(code = 202, body = body) + } + setupProvider(callback) + + val response = client.newCall( buildRequest( - "http://www.test.fr/request2", + url, method = "GET" ) ).execute() - assertEquals(result1, response1.body()?.string()) - assertEquals(result2, response2.body()?.string()) - } - - @Test - fun `should not allow init an interceptor in record mode with no recorder`() { - val exception = assertThrows { setupProvider(RECORD) { null } } - assertEquals(NO_ROOT_FOLDER_ERROR, exception.message) - assertFalse(::interceptor.isInitialized) - } + assertEquals(202, response.code()) + assertEquals(body, response.body()?.string()) + } - @Test - fun `should not allow to record requests if recorder is not set`() { - setupProvider { null } - val exception = assertThrows { - interceptor.mode = RECORD + @Test + @DisplayName("When several callbacks are provided, then they should be called in turn to find the appropriate response") + fun `should support multiple callbacks`() { + val result1 = "First mock" + val result2 = "Second mock" + + interceptor = MockResponseInterceptor.Builder() + .useDynamicMocks { + if (it.url().toString().contains("1")) + ResponseDescriptor(body = result1) + else null + }.useDynamicMocks { + ResponseDescriptor(body = result2) + } + .setInterceptorStatus(ENABLED) + .build() + + client = OkHttpClient.Builder().addInterceptor(interceptor).build() + + val response1 = + client.newCall( + buildRequest( + "http://www.test.fr/request1", + method = "GET" + ) + ).execute() + val response2 = + client.newCall( + buildRequest( + "http://www.test.fr/request2", + method = "GET" + ) + ).execute() + + assertEquals(result1, response1.body()?.string()) + assertEquals(result2, response2.body()?.string()) } - assertEquals(NO_RECORDER_ERROR, exception.message) - } - private fun setupProvider(callback: RequestCallback) { - interceptor = MockResponseInterceptor.Builder() - .useDynamicMocks(callback) - .setInterceptorStatus(ENABLED) - .build() + private fun setupProvider(callback: RequestCallback) { + interceptor = MockResponseInterceptor.Builder() + .useDynamicMocks(callback) + .setInterceptorStatus(ENABLED) + .build() - client = OkHttpClient.Builder().addInterceptor(interceptor).build() + client = OkHttpClient.Builder().addInterceptor(interceptor).build() - } + } - private fun setupProvider( - status: MockResponseInterceptor.Mode = ENABLED, - callback: (Request) -> ResponseDescriptor? - ) { - interceptor = MockResponseInterceptor.Builder() - .useDynamicMocks(callback) - .setInterceptorStatus(status) - .build() + private fun setupProvider( + status: MockResponseInterceptor.Mode = ENABLED, + callback: (Request) -> ResponseDescriptor? + ) { + interceptor = MockResponseInterceptor.Builder() + .useDynamicMocks(callback) + .setInterceptorStatus(status) + .build() + + client = OkHttpClient.Builder().addInterceptor(interceptor).build() + } - client = OkHttpClient.Builder().addInterceptor(interceptor).build() } companion object { const val url = "http://www.test.fr/path1?param=1" } -} \ No newline at end of file +} diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/RecordTests.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/RecordTests.kt index 8a0db2f6..43ba0a76 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/RecordTests.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/RecordTests.kt @@ -19,13 +19,21 @@ package fr.speekha.httpmocker.interceptor import fr.speekha.httpmocker.Mapper import fr.speekha.httpmocker.MockResponseInterceptor import fr.speekha.httpmocker.MockResponseInterceptor.Mode.RECORD +import fr.speekha.httpmocker.NO_RECORDER_ERROR +import fr.speekha.httpmocker.NO_ROOT_FOLDER_ERROR import fr.speekha.httpmocker.model.Header import fr.speekha.httpmocker.model.Matcher import fr.speekha.httpmocker.model.RequestDescriptor import fr.speekha.httpmocker.model.ResponseDescriptor import okhttp3.OkHttpClient import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS import org.junit.jupiter.api.assertThrows import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments @@ -39,168 +47,144 @@ import java.util.stream.Stream class RecordTests : TestWithServer() { - @ParameterizedTest(name = "{0}") - @MethodSource("data") - fun `should let requests through when recording`(title: String, mapper: Mapper) { - enqueueServerResponse(200, "body") - setUpInterceptor(mapper) + @Nested + @DisplayName("Given an mock interceptor with no recorder set") + inner class NoRecorderSet { + private lateinit var interceptor: MockResponseInterceptor + + @Test + @DisplayName("When building the interceptor in record mode, then an error should occur") + fun `should not allow to init an interceptor in record mode with no recorder`() { + val exception = assertThrows { setupProvider(RECORD) } + assertEquals(NO_ROOT_FOLDER_ERROR, exception.message) + Assertions.assertFalse(::interceptor.isInitialized) + } - val response = executeGetRequest("record/request") + @Test + @DisplayName("When setting the interceptor status to record mode, then an error should occur") + fun `should not allow to record requests if recorder is not set`() { + setupProvider() + val exception = assertThrows { + interceptor.mode = RECORD + } + assertEquals(NO_RECORDER_ERROR, exception.message) + } - assertResponseCode(response, 200, "OK") - assertEquals("body", response.body()?.string()) - } + private fun setupProvider( + status: MockResponseInterceptor.Mode = MockResponseInterceptor.Mode.ENABLED + ) { + interceptor = MockResponseInterceptor.Builder() + .useDynamicMocks { null } + .setInterceptorStatus(status) + .build() - @ParameterizedTest(name = "{0}") - @MethodSource("data") - fun `should let requests through when recording even if saving fails`( - title: String, - mapper: Mapper - ) { - enqueueServerResponse(200, "body") - setUpInterceptor(mapper, "", false) + client = OkHttpClient.Builder().addInterceptor(interceptor).build() + } + } - val response = executeGetRequest("record/request") + @Nested + @TestInstance(PER_CLASS) + @DisplayName("Given an mock interceptor in record mode") + inner class InterceptionTest { + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("data") + @DisplayName("When a request is recorded, then it should not be blocked") + fun `should let requests through when recording`(title: String, mapper: Mapper) { + enqueueServerResponse(200, "body") + setUpInterceptor(mapper) + + val response = executeGetRequest("record/request") + + assertResponseCode(response, 200, "OK") + assertEquals("body", response.body()?.string()) + } - assertResponseCode(response, 200, "OK") - assertEquals("body", response.body()?.string()) - } + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("data") + @DisplayName("When recording a request fails, then it should not interfere with the request") + fun `should let requests through when recording even if saving fails`( + title: String, + mapper: Mapper + ) { + enqueueServerResponse(200, "body") + setUpInterceptor(mapper, "", false) - @ParameterizedTest(name = "{0}") - @MethodSource("data") - fun `recording failure should return an error if desired`( - title: String, - mapper: Mapper - ) { - enqueueServerResponse(200, "body") - setUpInterceptor(mapper, "", true) + val response = executeGetRequest("record/request") - assertThrows { - executeGetRequest("record/request") + assertResponseCode(response, 200, "OK") + assertEquals("body", response.body()?.string()) } - } - @ParameterizedTest(name = "{0}") - @MethodSource("data") - fun `should store requests and responses in the proper locations when recording`( - title: String, - mapper: Mapper - ) { - enqueueServerResponse(200, "body") - setUpInterceptor(mapper) + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("data") + @DisplayName("When recording a request fails and errors are expected, then the error should be returned") + fun `recording failure should return an error if desired`(title: String, mapper: Mapper) { + enqueueServerResponse(200, "body") + setUpInterceptor(mapper, "", true) - executeGetRequest("record/request") + assertThrows { + executeGetRequest("record/request") + } + } - assertFileExists("$SAVE_FOLDER/record/request.json") - assertFileExists("$SAVE_FOLDER/record/request_body_0.txt") + fun data(): Stream = mappers } - @ParameterizedTest(name = "{0}") - @MethodSource("data") - fun `should name body file correctly when last path segment is empty`( - title: String, - mapper: Mapper - ) { - enqueueServerResponse(200, "body") - setUpInterceptor(mapper) + @Nested + @TestInstance(PER_CLASS) + @DisplayName("Given an mock interceptor in record mode with a root folder") + inner class RecordTest { + + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("data") + @DisplayName("When recording a request, then scenario and response body files should be created in that folder") + fun `should store requests and responses in the proper locations when recording`( + title: String, + mapper: Mapper + ) { + enqueueServerResponse(200, "body") + setUpInterceptor(mapper) - executeGetRequest("record/") - - assertFileExists("$SAVE_FOLDER/record/index.json") - assertFileExists("$SAVE_FOLDER/record/index_body_0.txt") - } + executeGetRequest("record/request") - @ParameterizedTest(name = "{0}") - @MethodSource("data") - fun `should store requests and responses when recording`(title: String, mapper: Mapper) { - enqueueServerResponse(200, "body", listOf("someKey" to "someValue")) - setUpInterceptor(mapper) - - executeRequest( - "request?param1=value1", - "POST", - "requestBody", - listOf("someHeader" to "someValue") - ) - - withFile("$SAVE_FOLDER/request.json") { - val result: List = - mapper.readMatches(it) - val expectedResult = Matcher( - RequestDescriptor( - method = "POST", - body = "requestBody", - params = mapOf("param1" to "value1"), - headers = listOf(Header("someHeader", "someValue")) - ), - ResponseDescriptor( - code = 200, - bodyFile = "request_body_0.txt", - mediaType = "text/plain", - headers = listOf( - Header("Content-Length", "4"), - Header("Content-Type", "text/plain"), - Header("someKey", "someValue") - ) - ) - ) - assertEquals(listOf(expectedResult), result) + assertFileExists("$SAVE_FOLDER/record/request.json") + assertFileExists("$SAVE_FOLDER/record/request_body_0.txt") } - withFile("$SAVE_FOLDER/request_body_0.txt") { - assertEquals("body", it.readAsString()) + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("data") + @DisplayName("When recording a request for a URL ending with a '/', then scenario files should be named with 'index'") + fun `should name body file correctly when last path segment is empty`( + title: String, + mapper: Mapper + ) { + enqueueServerResponse(200, "body") + setUpInterceptor(mapper) + + executeGetRequest("record/") + + assertFileExists("$SAVE_FOLDER/record/index.json") + assertFileExists("$SAVE_FOLDER/record/index_body_0.txt") } - } - @ParameterizedTest(name = "{0}") - @MethodSource("data") - fun `should handle null request and response bodies when recording`( - title: String, - mapper: Mapper - ) { - enqueueServerResponse(200, null) - setUpInterceptor(mapper) - - executeRequest("request", "GET", null) - - withFile("$SAVE_FOLDER/request.json") { - val result: List = - mapper.readMatches(it) - val expectedResult = Matcher( - RequestDescriptor(method = "GET"), - ResponseDescriptor( - code = 200, - mediaType = "text/plain", - headers = listOf( - Header("Content-Length", "0"), - Header("Content-Type", "text/plain") - ) - ) + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("data") + @DisplayName("When recording a request, then content of scenario files should be correct") + fun `should store requests and responses when recording`(title: String, mapper: Mapper) { + enqueueServerResponse(200, "body", listOf("someKey" to "someValue")) + setUpInterceptor(mapper) + + executeRequest( + "request?param1=value1", + "POST", + "requestBody", + listOf("someHeader" to "someValue") ) - assertEquals(listOf(expectedResult), result) - } - } - @ParameterizedTest(name = "{0}") - @MethodSource("data") - fun `should update existing descriptors when recording`(title: String, mapper: Mapper) { - enqueueServerResponse(200, "body", listOf("someKey" to "someValue")) - enqueueServerResponse(200, "second body") - setUpInterceptor(mapper) - - executeRequest( - "request?param1=value1", - "POST", - "requestBody", - listOf("someHeader" to "someValue") - ) - executeGetRequest("request") - - withFile("$SAVE_FOLDER/request.json") { - val result: List = - mapper.readMatches(it) - val expectedResult = listOf( - Matcher( + withFile("$SAVE_FOLDER/request.json") { + val result: List = + mapper.readMatches(it) + val expectedResult = Matcher( RequestDescriptor( method = "POST", body = "requestBody", @@ -217,71 +201,152 @@ class RecordTests : TestWithServer() { Header("someKey", "someValue") ) ) - ), - Matcher( + ) + assertEquals(listOf(expectedResult), result) + } + + withFile("$SAVE_FOLDER/request_body_0.txt") { + assertEquals("body", it.readAsString()) + } + } + + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("data") + @DisplayName("When recording a request or response with a null body, then body should be empty in scenario files") + fun `should handle null request and response bodies when recording`( + title: String, + mapper: Mapper + ) { + enqueueServerResponse(200, null) + setUpInterceptor(mapper) + + executeRequest("request", "GET", null) + + withFile("$SAVE_FOLDER/request.json") { + val result: List = + mapper.readMatches(it) + val expectedResult = Matcher( RequestDescriptor(method = "GET"), ResponseDescriptor( code = 200, - bodyFile = "request_body_1.txt", mediaType = "text/plain", headers = listOf( - Header("Content-Length", "11"), + Header("Content-Length", "0"), Header("Content-Type", "text/plain") ) ) ) - ) - assertEquals(expectedResult, result) + assertEquals(listOf(expectedResult), result) + } } - withFile("$SAVE_FOLDER/request_body_0.txt") { - assertEquals("body", it.readAsString()) - } - withFile("$SAVE_FOLDER/request_body_1.txt") { - assertEquals("second body", it.readAsString()) + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("data") + @DisplayName("When a scenario already exists for a request, then the scenario should be completed with the new one") + fun `should update existing descriptors when recording`(title: String, mapper: Mapper) { + enqueueServerResponse(200, "body", listOf("someKey" to "someValue")) + enqueueServerResponse(200, "second body") + setUpInterceptor(mapper) + + executeRequest( + "request?param1=value1", + "POST", + "requestBody", + listOf("someHeader" to "someValue") + ) + executeGetRequest("request") + + withFile("$SAVE_FOLDER/request.json") { + val result: List = + mapper.readMatches(it) + val expectedResult = listOf( + Matcher( + RequestDescriptor( + method = "POST", + body = "requestBody", + params = mapOf("param1" to "value1"), + headers = listOf(Header("someHeader", "someValue")) + ), + ResponseDescriptor( + code = 200, + bodyFile = "request_body_0.txt", + mediaType = "text/plain", + headers = listOf( + Header("Content-Length", "4"), + Header("Content-Type", "text/plain"), + Header("someKey", "someValue") + ) + ) + ), + Matcher( + RequestDescriptor(method = "GET"), + ResponseDescriptor( + code = 200, + bodyFile = "request_body_1.txt", + mediaType = "text/plain", + headers = listOf( + Header("Content-Length", "11"), + Header("Content-Type", "text/plain") + ) + ) + ) + ) + assertEquals(expectedResult, result) + } + + withFile("$SAVE_FOLDER/request_body_0.txt") { + assertEquals("body", it.readAsString()) + } + withFile("$SAVE_FOLDER/request_body_1.txt") { + assertEquals("second body", it.readAsString()) + } } - } - @ParameterizedTest(name = "{0}") - @MethodSource("data") - fun `should add proper extension to response files`(title: String, mapper: Mapper) { - enqueueServerResponse(200, "body", contentType = "image/png") - enqueueServerResponse(200, "body", contentType = "application/json") - setUpInterceptor(mapper) + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("data") + @DisplayName("When recording a response body, then the file should have the proper extension") + fun `should add proper extension to response files`(title: String, mapper: Mapper) { + enqueueServerResponse(200, "body", contentType = "image/png") + enqueueServerResponse(200, "body", contentType = "application/json") + setUpInterceptor(mapper) - executeGetRequest("record/request1") - executeGetRequest("record/request2") + executeGetRequest("record/request1") + executeGetRequest("record/request2") - assertFileExists("$SAVE_FOLDER/record/request1_body_0.png") - assertFileExists("$SAVE_FOLDER/record/request2_body_0.json") - } + assertFileExists("$SAVE_FOLDER/record/request1_body_0.png") + assertFileExists("$SAVE_FOLDER/record/request2_body_0.json") + } - @ParameterizedTest(name = "{0}") - @MethodSource("data") - fun `should match indexes in descriptor file and actual response file name`( - title: String, - mapper: Mapper - ) { - enqueueServerResponse(200, "body", contentType = "image/png") - enqueueServerResponse(200, "body", contentType = "application/json") - setUpInterceptor(mapper) + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("data") + @DisplayName("When several matches exist for a request, then the body file should have the same index as the request in the scenario") + fun `should match indexes in descriptor file and actual response file name`( + title: String, + mapper: Mapper + ) { + enqueueServerResponse(200, "body", contentType = "image/png") + enqueueServerResponse(200, "body", contentType = "application/json") + setUpInterceptor(mapper) - executeGetRequest("record/request") - executeGetRequest("record/request") + executeGetRequest("record/request") + executeGetRequest("record/request") - assertFileExists("$SAVE_FOLDER/record/request_body_0.png") - assertFileExists("$SAVE_FOLDER/record/request_body_1.json") - } + assertFileExists("$SAVE_FOLDER/record/request_body_0.png") + assertFileExists("$SAVE_FOLDER/record/request_body_1.json") + } - @AfterEach - fun clearFolder() { - val folder = File(SAVE_FOLDER) - if (folder.exists()) { - Files.walk(folder.toPath()) - .sorted(Collections.reverseOrder()) - .map(Path::toFile) - .forEach { it.delete() } + @AfterEach + fun clearFolder() { + val folder = File(SAVE_FOLDER) + if (folder.exists()) { + Files.walk(folder.toPath()) + .sorted(Collections.reverseOrder()) + .map(Path::toFile) + .forEach { it.delete() } + } } + + fun data(): Stream = mappers } private fun setUpInterceptor( @@ -306,9 +371,6 @@ class RecordTests : TestWithServer() { companion object { private const val SAVE_FOLDER = "testFolder" - - @JvmStatic - fun data(): Stream = - mappers } + } \ No newline at end of file diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/StaticMockTests.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/StaticMockTests.kt index 166abe7f..feaa633c 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/StaticMockTests.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/StaticMockTests.kt @@ -23,7 +23,8 @@ import com.nhaarman.mockitokotlin2.verify import com.nhaarman.mockitokotlin2.whenever import fr.speekha.httpmocker.Mapper import fr.speekha.httpmocker.MockResponseInterceptor -import fr.speekha.httpmocker.MockResponseInterceptor.Mode.* +import fr.speekha.httpmocker.MockResponseInterceptor.Mode.ENABLED +import fr.speekha.httpmocker.MockResponseInterceptor.Mode.MIXED import fr.speekha.httpmocker.buildRequest import fr.speekha.httpmocker.model.Matcher import fr.speekha.httpmocker.model.RequestDescriptor @@ -56,19 +57,7 @@ class StaticMockTests : TestWithServer() { } } - @ParameterizedTest(name = "{0}") - @MethodSource("data") - fun `should not interfere with requests when disabled`(title: String, mapper: Mapper) { - setUpInterceptor(DISABLED, mapper) - enqueueServerResponse(200, "body") - - val response = executeGetRequest("") - - assertResponseCode(response, 200, "OK") - assertEquals("body", response.body()?.string()) - } - - @ParameterizedTest(name = "{0}") + @ParameterizedTest(name = "Mapper: {0}") @MethodSource("data") fun `should return a 404 error when response is not found`(title: String, mapper: Mapper) { setUpInterceptor(ENABLED, mapper) @@ -78,7 +67,7 @@ class StaticMockTests : TestWithServer() { assertResponseCode(response, 404, "Not Found") } - @ParameterizedTest(name = "{0}") + @ParameterizedTest(name = "Mapper: {0}") @MethodSource("data") fun `should return a 404 error when an exception occurs`(title: String, mapper: Mapper) { whenever(loadingLambda.invoke(any())) doAnswer { @@ -91,7 +80,7 @@ class StaticMockTests : TestWithServer() { assertResponseCode(response, 404, "Not Found") } - @ParameterizedTest(name = "{0}") + @ParameterizedTest(name = "Mapper: {0}") @MethodSource("data") fun `should return a 404 error when no response matches the criteria`( title: String, @@ -107,7 +96,7 @@ class StaticMockTests : TestWithServer() { assertResponseCode(response, 404, "Not Found") } - @ParameterizedTest(name = "{0}") + @ParameterizedTest(name = "Mapper: {0}") @MethodSource("data") fun `should return a 200 when response is found`(title: String, mapper: Mapper) { setUpInterceptor(ENABLED, mapper) @@ -117,7 +106,7 @@ class StaticMockTests : TestWithServer() { assertResponseCode(response, 200, "OK") } - @ParameterizedTest(name = "{0}") + @ParameterizedTest(name = "Mapper: {0}") @MethodSource("data") fun `should return a predefined response body from json descriptor`( title: String, @@ -131,7 +120,7 @@ class StaticMockTests : TestWithServer() { assertEquals("simple body", response.body()?.string()) } - @ParameterizedTest(name = "{0}") + @ParameterizedTest(name = "Mapper: {0}") @MethodSource("data") fun `should return a predefined response body from separate file`( title: String, @@ -145,7 +134,7 @@ class StaticMockTests : TestWithServer() { assertEquals("separate body file", response.body()?.string()) } - @ParameterizedTest(name = "{0}") + @ParameterizedTest(name = "Mapper: {0}") @MethodSource("data") fun `should return a predefined response body from separate file in the same folder`( title: String, @@ -159,7 +148,7 @@ class StaticMockTests : TestWithServer() { assertEquals("separate body file", response.body()?.string()) } - @ParameterizedTest(name = "{0}") + @ParameterizedTest(name = "Mapper: {0}") @MethodSource("data") fun `should return a predefined response body from separate file in a different folder`( title: String, @@ -173,7 +162,7 @@ class StaticMockTests : TestWithServer() { assertEquals("separate body file", response.body()?.string()) } - @ParameterizedTest(name = "{0}") + @ParameterizedTest(name = "Mapper: {0}") @MethodSource("data") fun `should return a predefined response body from separate file in a parent folder`( title: String, @@ -187,7 +176,7 @@ class StaticMockTests : TestWithServer() { assertEquals("separate body file", response.body()?.string()) } - @ParameterizedTest(name = "{0}") + @ParameterizedTest(name = "Mapper: {0}") @MethodSource("data") fun `should return proper headers`(title: String, mapper: Mapper) { setUpInterceptor(ENABLED, mapper) @@ -198,7 +187,7 @@ class StaticMockTests : TestWithServer() { assertEquals("simple header", response.header("testHeader")) } - @ParameterizedTest(name = "{0}") + @ParameterizedTest(name = "Mapper: {0}") @MethodSource("data") fun `should handle redirects`(title: String, mapper: Mapper) { setUpInterceptor(ENABLED, mapper) @@ -209,7 +198,7 @@ class StaticMockTests : TestWithServer() { assertEquals("http://www.google.com", response.header("Location")) } - @ParameterizedTest(name = "{0}") + @ParameterizedTest(name = "Mapper: {0}") @MethodSource("data") fun `should handle media type`(title: String, mapper: Mapper) { setUpInterceptor(ENABLED, mapper) @@ -222,7 +211,7 @@ class StaticMockTests : TestWithServer() { assertEquals("json", response.body()?.contentType()?.subtype()) } - @ParameterizedTest(name = "{0}") + @ParameterizedTest(name = "Mapper: {0}") @MethodSource("data") fun `should select response based on query params`(title: String, mapper: Mapper) { setUpInterceptor(ENABLED, mapper) @@ -234,7 +223,7 @@ class StaticMockTests : TestWithServer() { assertEquals("param B", param2) } - @ParameterizedTest(name = "{0}") + @ParameterizedTest(name = "Mapper: {0}") @MethodSource("data") fun `should select response based on absent query params`(title: String, mapper: Mapper) { setUpInterceptor(ENABLED, mapper) @@ -246,7 +235,7 @@ class StaticMockTests : TestWithServer() { assertEquals(404, param2.code()) } - @ParameterizedTest(name = "{0}") + @ParameterizedTest(name = "Mapper: {0}") @MethodSource("data") fun `should select response based on URL path`(title: String, mapper: Mapper) { val policy = SingleFilePolicy("single_file.json") @@ -266,7 +255,7 @@ class StaticMockTests : TestWithServer() { assertEquals("based on URL", client.newCall(request).execute().body()?.string()) } - @ParameterizedTest(name = "{0}") + @ParameterizedTest(name = "Mapper: {0}") @MethodSource("data") fun `should select response based on host`(title: String, mapper: Mapper) { val policy = SingleFilePolicy("single_file.json") @@ -286,7 +275,7 @@ class StaticMockTests : TestWithServer() { assertEquals("based on host", client.newCall(request).execute().body()?.string()) } - @ParameterizedTest(name = "{0}") + @ParameterizedTest(name = "Mapper: {0}") @MethodSource("data") fun `should select response based on port`(title: String, mapper: Mapper) { val policy = SingleFilePolicy("single_file.json") @@ -306,7 +295,7 @@ class StaticMockTests : TestWithServer() { assertEquals("based on port", client.newCall(request).execute().body()?.string()) } - @ParameterizedTest(name = "{0}") + @ParameterizedTest(name = "Mapper: {0}") @MethodSource("data") fun `should select response based on headers`(title: String, mapper: Mapper) { setUpInterceptor(ENABLED, mapper) @@ -335,7 +324,7 @@ class StaticMockTests : TestWithServer() { assertEquals("with headers", headers) } - @ParameterizedTest(name = "{0}") + @ParameterizedTest(name = "Mapper: {0}") @MethodSource("data") fun `should select response based on absent headers`(title: String, mapper: Mapper) { setUpInterceptor(ENABLED, mapper) @@ -347,7 +336,7 @@ class StaticMockTests : TestWithServer() { assertEquals(404, extraHeader.code()) } - @ParameterizedTest(name = "{0}") + @ParameterizedTest(name = "Mapper: {0}") @MethodSource("data") fun `should take http protocol into account`(title: String, mapper: Mapper) { setUpInterceptor(ENABLED, mapper) @@ -357,7 +346,7 @@ class StaticMockTests : TestWithServer() { assertEquals("HTTP", get) } - @ParameterizedTest(name = "{0}") + @ParameterizedTest(name = "Mapper: {0}") @MethodSource("data") fun `should take http method into account`(title: String, mapper: Mapper) { setUpInterceptor(ENABLED, mapper) @@ -373,7 +362,7 @@ class StaticMockTests : TestWithServer() { assertEquals("delete", delete) } - @ParameterizedTest(name = "{0}") + @ParameterizedTest(name = "Mapper: {0}") @MethodSource("data") fun `should select response based on request body`(title: String, mapper: Mapper) { setUpInterceptor(ENABLED, mapper) @@ -386,7 +375,7 @@ class StaticMockTests : TestWithServer() { } - @ParameterizedTest(name = "{0}") + @ParameterizedTest(name = "Mapper: {0}") @MethodSource("data") fun `should select response based on exact matches`(title: String, mapper: Mapper) { setUpInterceptor(ENABLED, mapper) @@ -403,7 +392,7 @@ class StaticMockTests : TestWithServer() { assertEquals(404, extraParam.code()) } - @ParameterizedTest(name = "{0}") + @ParameterizedTest(name = "Mapper: {0}") @MethodSource("data") fun `should allow to delay all responses`(title: String, mapper: Mapper) { setUpInterceptor(ENABLED, mapper) @@ -417,7 +406,7 @@ class StaticMockTests : TestWithServer() { assertTrue(delay >= threshold, "Time was $delay (< $threshold ms)") } - @ParameterizedTest(name = "{0}") + @ParameterizedTest(name = "Mapper: {0}") @MethodSource("data") fun `should allow to delay responses based on configuration`(title: String, mapper: Mapper) { setUpInterceptor(ENABLED, mapper) @@ -435,7 +424,7 @@ class StaticMockTests : TestWithServer() { assertTrue(noDelay < threshold, "Time without delay was $noDelay (> $threshold ms)") } - @ParameterizedTest(name = "{0}") + @ParameterizedTest(name = "Mapper: {0}") @MethodSource("data") fun `should delegate path resolutions`(title: String, mapper: Mapper) { setUpInterceptor(ENABLED, mapper) @@ -446,7 +435,7 @@ class StaticMockTests : TestWithServer() { verify(filingPolicy).getPath(request) } - @ParameterizedTest(name = "{0}") + @ParameterizedTest(name = "Mapper: {0}") @MethodSource("data") fun `should support mixed mode to execute request when no response is found locally`( title: String, @@ -464,7 +453,7 @@ class StaticMockTests : TestWithServer() { assertEquals("simple body", localResponse.body()?.string()) } - @ParameterizedTest(name = "{0}") + @ParameterizedTest(name = "Mapper: {0}") @MethodSource("data") fun `should allow to stack several interceptors thanks to mixed mode`( title: String, @@ -507,7 +496,7 @@ class StaticMockTests : TestWithServer() { assertEquals("server response", executeGetRequest("serverMatch").body()?.string()) } - @ParameterizedTest(name = "{0}") + @ParameterizedTest(name = "Mapper: {0}") @MethodSource("data") fun `should support dynamic and static mocks together`(title: String, mapper: Mapper) { val result1 = "Dynamic" @@ -559,7 +548,6 @@ class StaticMockTests : TestWithServer() { companion object { @JvmStatic - fun data(): Stream = - mappers + fun data(): Stream = mappers } } \ No newline at end of file diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/TestWithServer.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/TestWithServer.kt index 625861eb..c5c858d7 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/TestWithServer.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/TestWithServer.kt @@ -114,18 +114,16 @@ open class TestWithServer { body ) } - - companion object { - val mappers: Stream - get() = Stream.of( - Arguments.of("Jackson", JacksonMapper()), - Arguments.of("Gson", GsonMapper()), - Arguments.of("Moshi", MoshiMapper()), - Arguments.of("Custom mapper", CustomMapper()), - Arguments.of( - "Kotlinx serialization", - KotlinxMapper(JsonFormatConverter()::import, JsonFormatConverter()::export) - ) - ) - } -} \ No newline at end of file +} + +val mappers: Stream + get() = Stream.of( + Arguments.of("Jackson", JacksonMapper()), + Arguments.of("Gson", GsonMapper()), + Arguments.of("Moshi", MoshiMapper()), + Arguments.of("Custom mapper", CustomMapper()), + Arguments.of( + "Kotlinx serialization", + KotlinxMapper(JsonFormatConverter()::import, JsonFormatConverter()::export) + ) + ) diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/policies/InMemoryPolicyTest.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/policies/InMemoryPolicyTest.kt index 0b7a6a35..e46a2f0d 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/policies/InMemoryPolicyTest.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/policies/InMemoryPolicyTest.kt @@ -29,7 +29,7 @@ import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test -@DisplayName("InMemoryPolicy") +@DisplayName("In-Memory Policy") class InMemoryPolicyTest { private val mapper = JacksonMapper() diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/policies/MirrorPathPolicyTest.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/policies/MirrorPathPolicyTest.kt index 70a93b3b..0634c1de 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/policies/MirrorPathPolicyTest.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/policies/MirrorPathPolicyTest.kt @@ -22,7 +22,7 @@ import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test -@DisplayName("MirrorPathPolicy") +@DisplayName("Mirror Path Policy") class MirrorPathPolicyTest { val policy: FilingPolicy = MirrorPathPolicy() diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/policies/ServerSpecificPolicyTest.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/policies/ServerSpecificPolicyTest.kt index 598d3d15..01f14c84 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/policies/ServerSpecificPolicyTest.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/policies/ServerSpecificPolicyTest.kt @@ -22,7 +22,7 @@ import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test -@DisplayName("ServerSpecificPolicy") +@DisplayName("Server Specific Policy") class ServerSpecificPolicyTest { private val policy: FilingPolicy = ServerSpecificPolicy() diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/policies/SingleFilePolicyTest.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/policies/SingleFilePolicyTest.kt index cf0fc3a1..cde66a01 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/policies/SingleFilePolicyTest.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/policies/SingleFilePolicyTest.kt @@ -22,7 +22,7 @@ import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test -@DisplayName("SingleFilePolicy") +@DisplayName("Single File Policy") class SingleFilePolicyTest { @Nested diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/policies/SingleFolderPolicyTest.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/policies/SingleFolderPolicyTest.kt index aeac562b..cce91aff 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/policies/SingleFolderPolicyTest.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/policies/SingleFolderPolicyTest.kt @@ -22,7 +22,7 @@ import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test -@DisplayName("SingleFolderPolicy") +@DisplayName("Single Folder Policy") class SingleFolderPolicyTest { @Nested From 9a3e8d9999877e4964762ddc04bc025cfdbadebe Mon Sep 17 00:00:00 2001 From: David Blanc Date: Wed, 7 Aug 2019 22:03:46 +0200 Subject: [PATCH 32/32] Refactoring tests for readability --- .../httpmocker/MockResponseInterceptor.kt | 9 +- .../httpmocker/scenario/StaticMockProvider.kt | 7 +- .../interceptor/DynamicMockTests.kt | 51 +- .../httpmocker/interceptor/RecordTests.kt | 28 +- .../httpmocker/interceptor/StaticMockTests.kt | 810 +++++++++--------- .../httpmocker/interceptor/TestWithServer.kt | 27 +- .../mappers/JsonStringReaderTest.kt | 15 +- 7 files changed, 502 insertions(+), 445 deletions(-) diff --git a/mocker/src/main/kotlin/fr/speekha/httpmocker/MockResponseInterceptor.kt b/mocker/src/main/kotlin/fr/speekha/httpmocker/MockResponseInterceptor.kt index cbef981c..0b9a2f10 100644 --- a/mocker/src/main/kotlin/fr/speekha/httpmocker/MockResponseInterceptor.kt +++ b/mocker/src/main/kotlin/fr/speekha/httpmocker/MockResponseInterceptor.kt @@ -81,8 +81,13 @@ private constructor( private fun mockResponse(request: Request): Response? = providers.asSequence() .mapNotNull { provider -> logger.info("Looking up mock scenario for $request in $provider") - provider.loadResponse(request)?.let { response -> - executeMockResponse(response, request, provider) + try { + provider.loadResponse(request)?.let { response -> + executeMockResponse(response, request, provider) + } + } catch (e: Throwable) { + logger.error("Scenario file could not be loaded", e) + null } } .firstOrNull() diff --git a/mocker/src/main/kotlin/fr/speekha/httpmocker/scenario/StaticMockProvider.kt b/mocker/src/main/kotlin/fr/speekha/httpmocker/scenario/StaticMockProvider.kt index ae673b6a..d447abff 100644 --- a/mocker/src/main/kotlin/fr/speekha/httpmocker/scenario/StaticMockProvider.kt +++ b/mocker/src/main/kotlin/fr/speekha/httpmocker/scenario/StaticMockProvider.kt @@ -34,16 +34,13 @@ internal class StaticMockProvider( private val matcher = RequestMatcher() - override fun loadResponse(request: Request): ResponseDescriptor? = try { + override fun loadResponse(request: Request): ResponseDescriptor? { val path = filingPolicy.getPath(request) logger.info("Loading scenarios from $path") - loadFileContent(path)?.let { stream -> + return loadFileContent(path)?.let { stream -> val list = mapper.readMatches(stream) matchRequest(request, list) } - } catch (e: Throwable) { - logger.error("Scenario file could not be loaded", e) - null } private fun matchRequest(request: Request, list: List): ResponseDescriptor? = diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/DynamicMockTests.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/DynamicMockTests.kt index 1ce112d2..c8e122b0 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/DynamicMockTests.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/DynamicMockTests.kt @@ -33,11 +33,11 @@ import org.junit.jupiter.api.Test class DynamicMockTests : TestWithServer() { @Nested - @DisplayName("Given an mock interceptor") - inner class DynamicTests { + @DisplayName("Given an mock interceptor that is disabled") + inner class DisabledInterceptor { @Test - @DisplayName("When interceptor is disabled, then it should not interfere with requests") + @DisplayName("When a request is made, then the interceptor should not interfere with it") fun `should not interfere with requests when disabled`() { setupProvider(DISABLED) { null } enqueueServerResponse(200, "body") @@ -47,6 +47,31 @@ class DynamicMockTests : TestWithServer() { assertResponseCode(response, 200, "OK") assertEquals("body", response.body()?.string()) } + } + + @Nested + @DisplayName("Given an enabled mock interceptor with a dynamic callback") + inner class DynamicTests { + + @Test + @DisplayName("When no response is provided, then a 404 error should occur") + fun `should return a 404 error when response is not found`() { + setupProvider(ENABLED) { null } + + val response = executeGetRequest("/unknown") + + assertResponseCode(response, 404, "Not Found") + } + + @Test + @DisplayName("When an error occurs while answering a request, then a 404 error should occur") + fun `should return a 404 error when an exception occurs`() { + setupProvider(ENABLED) { error("Unexpected error") } + + val response = executeGetRequest("/unknown") + + assertResponseCode(response, 404, "Not Found") + } @Test @DisplayName("When a lambda is provided, then it should be used to answer requests") @@ -134,18 +159,18 @@ class DynamicMockTests : TestWithServer() { } - private fun setupProvider( - status: MockResponseInterceptor.Mode = ENABLED, - callback: (Request) -> ResponseDescriptor? - ) { - interceptor = MockResponseInterceptor.Builder() - .useDynamicMocks(callback) - .setInterceptorStatus(status) - .build() + } - client = OkHttpClient.Builder().addInterceptor(interceptor).build() - } + private fun setupProvider( + status: MockResponseInterceptor.Mode = ENABLED, + callback: (Request) -> ResponseDescriptor? + ) { + interceptor = MockResponseInterceptor.Builder() + .useDynamicMocks(callback) + .setInterceptorStatus(status) + .build() + client = OkHttpClient.Builder().addInterceptor(interceptor).build() } companion object { diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/RecordTests.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/RecordTests.kt index 43ba0a76..6564b614 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/RecordTests.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/RecordTests.kt @@ -32,8 +32,6 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance -import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS import org.junit.jupiter.api.assertThrows import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments @@ -83,11 +81,10 @@ class RecordTests : TestWithServer() { } @Nested - @TestInstance(PER_CLASS) @DisplayName("Given an mock interceptor in record mode") inner class InterceptionTest { @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") @DisplayName("When a request is recorded, then it should not be blocked") fun `should let requests through when recording`(title: String, mapper: Mapper) { enqueueServerResponse(200, "body") @@ -100,7 +97,7 @@ class RecordTests : TestWithServer() { } @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") @DisplayName("When recording a request fails, then it should not interfere with the request") fun `should let requests through when recording even if saving fails`( title: String, @@ -116,7 +113,7 @@ class RecordTests : TestWithServer() { } @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") @DisplayName("When recording a request fails and errors are expected, then the error should be returned") fun `recording failure should return an error if desired`(title: String, mapper: Mapper) { enqueueServerResponse(200, "body") @@ -127,16 +124,15 @@ class RecordTests : TestWithServer() { } } - fun data(): Stream = mappers + fun data(): Stream = mappers() } @Nested - @TestInstance(PER_CLASS) @DisplayName("Given an mock interceptor in record mode with a root folder") inner class RecordTest { @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") @DisplayName("When recording a request, then scenario and response body files should be created in that folder") fun `should store requests and responses in the proper locations when recording`( title: String, @@ -152,7 +148,7 @@ class RecordTests : TestWithServer() { } @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") @DisplayName("When recording a request for a URL ending with a '/', then scenario files should be named with 'index'") fun `should name body file correctly when last path segment is empty`( title: String, @@ -168,7 +164,7 @@ class RecordTests : TestWithServer() { } @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") @DisplayName("When recording a request, then content of scenario files should be correct") fun `should store requests and responses when recording`(title: String, mapper: Mapper) { enqueueServerResponse(200, "body", listOf("someKey" to "someValue")) @@ -211,7 +207,7 @@ class RecordTests : TestWithServer() { } @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") @DisplayName("When recording a request or response with a null body, then body should be empty in scenario files") fun `should handle null request and response bodies when recording`( title: String, @@ -241,7 +237,7 @@ class RecordTests : TestWithServer() { } @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") @DisplayName("When a scenario already exists for a request, then the scenario should be completed with the new one") fun `should update existing descriptors when recording`(title: String, mapper: Mapper) { enqueueServerResponse(200, "body", listOf("someKey" to "someValue")) @@ -303,7 +299,7 @@ class RecordTests : TestWithServer() { } @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") @DisplayName("When recording a response body, then the file should have the proper extension") fun `should add proper extension to response files`(title: String, mapper: Mapper) { enqueueServerResponse(200, "body", contentType = "image/png") @@ -318,7 +314,7 @@ class RecordTests : TestWithServer() { } @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") @DisplayName("When several matches exist for a request, then the body file should have the same index as the request in the scenario") fun `should match indexes in descriptor file and actual response file name`( title: String, @@ -345,8 +341,6 @@ class RecordTests : TestWithServer() { .forEach { it.delete() } } } - - fun data(): Stream = mappers } private fun setUpInterceptor( diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/StaticMockTests.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/StaticMockTests.kt index feaa633c..1d29cbdf 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/StaticMockTests.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/StaticMockTests.kt @@ -36,11 +36,11 @@ import okhttp3.OkHttpClient import okhttp3.Request import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource import java.io.InputStream -import java.util.stream.Stream import kotlin.system.measureTimeMillis class StaticMockTests : TestWithServer() { @@ -57,482 +57,523 @@ class StaticMockTests : TestWithServer() { } } - @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") - fun `should return a 404 error when response is not found`(title: String, mapper: Mapper) { - setUpInterceptor(ENABLED, mapper) + @Nested + @DisplayName("Given an enabled mock interceptor with static scenarios") + inner class StaticTests { + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") + @DisplayName("When a request is answered with a scenario, then its path should be computed by the file policy") + fun `should delegate path resolutions`(title: String, mapper: Mapper) { + setUpInterceptor(ENABLED, mapper) - val response = executeGetRequest("/unknown") + val request = initRequest("/request") + client.newCall(request).execute() - assertResponseCode(response, 404, "Not Found") - } - - @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") - fun `should return a 404 error when an exception occurs`(title: String, mapper: Mapper) { - whenever(loadingLambda.invoke(any())) doAnswer { - error("Loading error") + verify(filingPolicy).getPath(request) } - setUpInterceptor(ENABLED, mapper) - val response = executeGetRequest("/unknown") + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") + @DisplayName("When no scenario file is provided, then a 404 error should occur") + fun `should return a 404 error when response is not found`(title: String, mapper: Mapper) { + setUpInterceptor(ENABLED, mapper) - assertResponseCode(response, 404, "Not Found") - } + val response = executeGetRequest("/unknown") - @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") - fun `should return a 404 error when no response matches the criteria`( - title: String, - mapper: Mapper - ) { - whenever(loadingLambda.invoke(any())) doAnswer { - error("Loading error") + assertResponseCode(response, 404, "Not Found") } - setUpInterceptor(ENABLED, mapper) - val response = executeGetRequest("/no_match") + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") + @DisplayName("When no requests in the file matches, then a 404 error should occur") + fun `should return a 404 error when no request matches the criteria`( + title: String, + mapper: Mapper + ) { + whenever(loadingLambda.invoke(any())) doAnswer { + error("Loading error") + } + setUpInterceptor(ENABLED, mapper) - assertResponseCode(response, 404, "Not Found") - } + val response = executeGetRequest("/no_match") - @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") - fun `should return a 200 when response is found`(title: String, mapper: Mapper) { - setUpInterceptor(ENABLED, mapper) + assertResponseCode(response, 404, "Not Found") + } - val response = executeGetRequest("/request") + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") + @DisplayName("When an error occurs while loading scenarios, then a 404 error should occur") + fun `should return a 404 error when an exception occurs`(title: String, mapper: Mapper) { + whenever(loadingLambda.invoke(any())) doAnswer { + error("Loading error") + } + setUpInterceptor(ENABLED, mapper) - assertResponseCode(response, 200, "OK") - } + val response = executeGetRequest("/unknown") - @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") - fun `should return a predefined response body from json descriptor`( - title: String, - mapper: Mapper - ) { - setUpInterceptor(ENABLED, mapper) + assertResponseCode(response, 404, "Not Found") + } - val response = executeGetRequest("/request") + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") + @DisplayName("When a response is found, then default HTTP code should be 200") + fun `should return a 200 when response is found`(title: String, mapper: Mapper) { + setUpInterceptor(ENABLED, mapper) - assertResponseCode(response, 200, "OK") - assertEquals("simple body", response.body()?.string()) - } + val response = executeGetRequest("/request") - @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") - fun `should return a predefined response body from separate file`( - title: String, - mapper: Mapper - ) { - setUpInterceptor(ENABLED, mapper) + assertResponseCode(response, 200, "OK") + } - val response = executeGetRequest("/body_file") + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") + @DisplayName("When a response is found and response body is in JSON scenario, then it should be loaded from scenario") + fun `should return a predefined response body from json descriptor`( + title: String, + mapper: Mapper + ) { + setUpInterceptor(ENABLED, mapper) - assertResponseCode(response, 200, "OK") - assertEquals("separate body file", response.body()?.string()) - } + val response = executeGetRequest("/request") - @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") - fun `should return a predefined response body from separate file in the same folder`( - title: String, - mapper: Mapper - ) { - setUpInterceptor(ENABLED, mapper) + assertEquals("simple body", response.body()?.string()) + } - val response = executeGetRequest("/folder/request_in_folder") + } - assertResponseCode(response, 200, "OK") - assertEquals("separate body file", response.body()?.string()) + @Nested + @DisplayName("Given an enabled mock interceptor with static and dynamic mocks") + inner class StaticAndDynamic { + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") + @DisplayName("When a request is answered, then dynamic mocks should be tried first before static ones") + fun `should support dynamic and static mocks together`(title: String, mapper: Mapper) { + val result1 = "Dynamic" + val result2 = "simple body" + + interceptor = MockResponseInterceptor.Builder() + .useDynamicMocks { + if (it.url().toString().contains("dynamic")) + ResponseDescriptor(body = result1) + else null + } + .decodeScenarioPathWith(filingPolicy) + .loadFileWith(loadingLambda) + .parseScenariosWith(mapper) + .setInterceptorStatus(ENABLED) + .build() + + client = OkHttpClient.Builder().addInterceptor(interceptor).build() + + val response1 = + client.newCall(buildRequest("http://www.test.fr/dynamic", method = "GET")) + .execute() + val response2 = + client.newCall(buildRequest("http://www.test.fr/request", method = "GET")) + .execute() + + assertEquals(result1, response1.body()?.string()) + assertEquals(result2, response2.body()?.string()) + } } - @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") - fun `should return a predefined response body from separate file in a different folder`( - title: String, - mapper: Mapper - ) { - setUpInterceptor(ENABLED, mapper) + @Nested + @DisplayName("Given an enabled mock interceptor with response bodies in separate files") + inner class SeparateFile { + @ParameterizedTest(name = "Mapper: {0}") + @DisplayName("When a response is found, then the body should be loaded from the file next to it") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") + fun `should return a predefined response body from separate file`( + title: String, + mapper: Mapper + ) { + setUpInterceptor(ENABLED, mapper) - val response = executeGetRequest("/request_in_other_folder") + val response = executeGetRequest("/body_file") - assertResponseCode(response, 200, "OK") - assertEquals("separate body file", response.body()?.string()) - } + assertEquals("separate body file", response.body()?.string()) + } - @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") - fun `should return a predefined response body from separate file in a parent folder`( - title: String, - mapper: Mapper - ) { - setUpInterceptor(ENABLED, mapper) + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") + @DisplayName("When scenario file path is not empty, then response body should be in the same folder by default") + fun `should return a predefined response body from separate file in the same folder`( + title: String, + mapper: Mapper + ) { + setUpInterceptor(ENABLED, mapper) - val response = executeGetRequest("/folder2/request_in_other_folder") + val response = executeGetRequest("/folder/request_in_folder") - assertResponseCode(response, 200, "OK") - assertEquals("separate body file", response.body()?.string()) - } + assertResponseCode(response, 200, "OK") + assertEquals("separate body file", response.body()?.string()) + } - @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") - fun `should return proper headers`(title: String, mapper: Mapper) { - setUpInterceptor(ENABLED, mapper) + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") + @DisplayName("When response body is in a child folder, then response body path should be read from that folder") + fun `should return a predefined response body from separate file in a different folder`( + title: String, + mapper: Mapper + ) { + setUpInterceptor(ENABLED, mapper) - val response = executeGetRequest("/request") + val response = executeGetRequest("/request_in_other_folder") - assertResponseCode(response, 200, "OK") - assertEquals("simple header", response.header("testHeader")) - } + assertResponseCode(response, 200, "OK") + assertEquals("separate body file", response.body()?.string()) + } - @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") - fun `should handle redirects`(title: String, mapper: Mapper) { - setUpInterceptor(ENABLED, mapper) + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") + @DisplayName("When response body is in a parent folder, then response body path should be read from that folder") + fun `should return a predefined response body from separate file in a parent folder`( + title: String, + mapper: Mapper + ) { + setUpInterceptor(ENABLED, mapper) - val response = executeGetRequest("/redirect") + val response = executeGetRequest("/folder2/request_in_other_folder") - assertResponseCode(response, 302, "Found") - assertEquals("http://www.google.com", response.header("Location")) + assertResponseCode(response, 200, "OK") + assertEquals("separate body file", response.body()?.string()) + } } - @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") - fun `should handle media type`(title: String, mapper: Mapper) { - setUpInterceptor(ENABLED, mapper) + @Nested + @DisplayName("Given an enabled mock interceptor and a response scenario") + inner class ResponseBuilding { - val response = executeGetRequest("/mediatype") + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") + @DisplayName("When a request is answered, then the proper headers should be set in the response") + fun `should return proper headers`(title: String, mapper: Mapper) { + setUpInterceptor(ENABLED, mapper) - assertResponseCode(response, 200, "OK") - assertEquals("application", response.body()?.contentType()?.type()) - assertEquals("application/json", response.header("Content-type")) - assertEquals("json", response.body()?.contentType()?.subtype()) - } + val response = executeGetRequest("/request") + + assertResponseCode(response, 200, "OK") + assertEquals("simple header", response.header("testHeader")) + } - @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") - fun `should select response based on query params`(title: String, mapper: Mapper) { - setUpInterceptor(ENABLED, mapper) + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") + @DisplayName("When the response is a redirect, then the response should have a HTTP code 302 and a location") + fun `should handle redirects`(title: String, mapper: Mapper) { + setUpInterceptor(ENABLED, mapper) - val param1 = executeGetRequest("/query_param?param=1").body()?.string() - val param2 = executeGetRequest("/query_param?param=2").body()?.string() + val response = executeGetRequest("/redirect") - assertEquals("param A", param1) - assertEquals("param B", param2) - } + assertResponseCode(response, 302, "Found") + assertEquals("http://www.google.com", response.header("Location")) + } - @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") - fun `should select response based on absent query params`(title: String, mapper: Mapper) { - setUpInterceptor(ENABLED, mapper) + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") + @DisplayName("When a request is answered, then the proper content type should be set in the response") + fun `should handle media type`(title: String, mapper: Mapper) { + setUpInterceptor(ENABLED, mapper) - val param1 = executeGetRequest("/absent_query_param?param1=1").body()?.string() - val param2 = executeGetRequest("/absent_query_param?param1=1¶m2=2") + val response = executeGetRequest("/mediatype") - assertEquals("Body found", param1) - assertEquals(404, param2.code()) - } + assertResponseCode(response, 200, "OK") + assertEquals("application", response.body()?.contentType()?.type()) + assertEquals("application/json", response.header("Content-type")) + assertEquals("json", response.body()?.contentType()?.subtype()) + } - @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") - fun `should select response based on URL path`(title: String, mapper: Mapper) { - val policy = SingleFilePolicy("single_file.json") - val interceptor = MockResponseInterceptor.Builder() - .decodeScenarioPathWith(policy) - .loadFileWith(loadingLambda) - .parseScenariosWith(mapper) - .setInterceptorStatus(ENABLED) - .build() + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") + @DisplayName("When a request with no specific delay is answered, then default delay should be used") + fun `should allow to delay all responses`(title: String, mapper: Mapper) { + setUpInterceptor(ENABLED, mapper) + interceptor.delay = 50 - client = OkHttpClient.Builder() - .addInterceptor(interceptor) - .build() + val delay = measureTimeMillis { + executeGetRequest("/request").body()?.string() + } - val request = - buildRequest("http://someHost.com:12345/aTestUrl") - assertEquals("based on URL", client.newCall(request).execute().body()?.string()) - } + val threshold = 50 + assertTrue(delay >= threshold, "Time was $delay (< $threshold ms)") + } - @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") - fun `should select response based on host`(title: String, mapper: Mapper) { - val policy = SingleFilePolicy("single_file.json") - val interceptor = MockResponseInterceptor.Builder() - .decodeScenarioPathWith(policy) - .loadFileWith(loadingLambda) - .parseScenariosWith(mapper) - .setInterceptorStatus(ENABLED) - .build() + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") + @DisplayName("When a request with a specific delay is answered, then that delay should be used") + fun `should allow to delay responses based on configuration`( + title: String, + mapper: Mapper + ) { + setUpInterceptor(ENABLED, mapper) + + val delay = measureTimeMillis { + executeGetRequest("/delay").body()?.string() + } - client = OkHttpClient.Builder() - .addInterceptor(interceptor) - .build() + val noDelay = measureTimeMillis { + executeGetRequest("/request").body()?.string() + } - val request = - buildRequest("http://hostTest.com:12345/anyUrl") - assertEquals("based on host", client.newCall(request).execute().body()?.string()) + val threshold = 50 + assertTrue(delay >= threshold, "Time was $delay (< $threshold ms)") + assertTrue(noDelay < threshold, "Time without delay was $noDelay (> $threshold ms)") + } } - @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") - fun `should select response based on port`(title: String, mapper: Mapper) { - val policy = SingleFilePolicy("single_file.json") - val interceptor = MockResponseInterceptor.Builder() - .decodeScenarioPathWith(policy) - .loadFileWith(loadingLambda) - .parseScenariosWith(mapper) - .setInterceptorStatus(ENABLED) - .build() + @Nested + @DisplayName("Given an enabled mock interceptor and a single file policy") + inner class UrlMatching { - client = OkHttpClient.Builder() - .addInterceptor(interceptor) - .build() + fun setupInterceptor(scenarioFile: String, mapper: Mapper) { + interceptor = MockResponseInterceptor.Builder() + .decodeScenarioPathWith(SingleFilePolicy(scenarioFile)) + .loadFileWith(loadingLambda) + .parseScenariosWith(mapper) + .setInterceptorStatus(ENABLED) + .build() - val request = - buildRequest("http://someHost.com:45612/anyUrl") - assertEquals("based on port", client.newCall(request).execute().body()?.string()) - } + client = OkHttpClient.Builder() + .addInterceptor(interceptor) + .build() - @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") - fun `should select response based on headers`(title: String, mapper: Mapper) { - setUpInterceptor(ENABLED, mapper) - - val noHeaders = executeGetRequest("/headers").body()?.string() - val headers = executeGetRequest( - "/headers", - listOf( - "header1" to "1", - "header1" to "2", - "header2" to "3" - ) - ).body()?.string() - val header1 = executeGetRequest( - "/headers", - listOf("header1" to "1") - ).body()?.string() - val header2 = executeGetRequest( - "/headers", - listOf("header2" to "2") - ).body()?.string() - - assertEquals("no header", noHeaders) - assertEquals("with header 1", header1) - assertEquals("with header 2", header2) - assertEquals("with headers", headers) - } + } - @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") - fun `should select response based on absent headers`(title: String, mapper: Mapper) { - setUpInterceptor(ENABLED, mapper) + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") + @DisplayName("When a request is answered, then it should match URL protocol") + fun `should take http protocol into account`(title: String, mapper: Mapper) { + setupInterceptor("protocol.json", mapper) - val correctHeader = executeGetRequest("/absent_header", listOf("header1" to "1")).body()?.string() - val extraHeader = executeGetRequest("/absent_header", listOf("header1" to "1", "header2" to "2")) + val get = executeGetRequest("/protocol").body()?.string() - assertEquals("Body found", correctHeader) - assertEquals(404, extraHeader.code()) - } + assertEquals("HTTP", get) + } - @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") - fun `should take http protocol into account`(title: String, mapper: Mapper) { - setUpInterceptor(ENABLED, mapper) + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") + @DisplayName("When a request is answered, then it should match the URL host") + fun `should select response based on host`(title: String, mapper: Mapper) { + setupInterceptor("single_file.json", mapper) - val get = executeGetRequest("/protocol").body()?.string() + val request = buildRequest("http://hostTest.com:12345/anyUrl") - assertEquals("HTTP", get) - } + assertEquals("based on host", client.newCall(request).execute().body()?.string()) + } - @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") - fun `should take http method into account`(title: String, mapper: Mapper) { - setUpInterceptor(ENABLED, mapper) + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") + @DisplayName("When a request is answered, then it should match the URL port") + fun `should select response based on port`(title: String, mapper: Mapper) { + setupInterceptor("single_file.json", mapper) - val get = executeGetRequest("/method").body()?.string() - val post = executeRequest("/method", "POST", "").body()?.string() - val put = executeRequest("/method", "PUT", "").body()?.string() - val delete = executeRequest("/method", "DELETE", "").body()?.string() + val request = buildRequest("http://someHost.com:45612/anyUrl") - assertEquals("get", get) - assertEquals("post", post) - assertEquals("put", put) - assertEquals("delete", delete) - } + assertEquals("based on port", client.newCall(request).execute().body()?.string()) + } - @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") - fun `should select response based on request body`(title: String, mapper: Mapper) { - setUpInterceptor(ENABLED, mapper) + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") + @DisplayName("When a request is answered, then it should match the URL path") + fun `should select response based on URL path`(title: String, mapper: Mapper) { + setupInterceptor("single_file.json", mapper) - val match = executeRequest("/body_matching", "POST", "azer1zere").body()?.string() - val noMatch = executeRequest("/body_matching", "POST", "azerzere").body()?.string() + val request = buildRequest("http://someHost.com:12345/aTestUrl") - assertEquals("matched", match) - assertEquals("no match", noMatch) + assertEquals("based on URL", client.newCall(request).execute().body()?.string()) + } } + @Nested + @DisplayName("Given an enabled mock interceptor and a scenario with multiple request patterns") + inner class RequestMatching { - @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") - fun `should select response based on exact matches`(title: String, mapper: Mapper) { - setUpInterceptor(ENABLED, mapper) + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") + fun `should take http method into account`(title: String, mapper: Mapper) { + setUpInterceptor(ENABLED, mapper) - val exactHeader = executeGetRequest("/exact_match", listOf("header1" to "1")) - val extraHeader = - executeGetRequest("/exact_match", listOf("header1" to "1", "header2" to "2")) - val exactParam = executeGetRequest("/exact_match?param1=1") - val extraParam = executeGetRequest("/exact_match?param1=1¶m2=2") + val get = executeGetRequest("/method").body()?.string() + val post = executeRequest("/method", "POST", "").body()?.string() + val put = executeRequest("/method", "PUT", "").body()?.string() + val delete = executeRequest("/method", "DELETE", "").body()?.string() - assertEquals("Exact headers", exactHeader.body()?.string()) - assertEquals(404, extraHeader.code()) - assertEquals("Exact params", exactParam.body()?.string()) - assertEquals(404, extraParam.code()) - } + assertEquals("get", get) + assertEquals("post", post) + assertEquals("put", put) + assertEquals("delete", delete) + } - @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") - fun `should allow to delay all responses`(title: String, mapper: Mapper) { - setUpInterceptor(ENABLED, mapper) - interceptor.delay = 50 + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") + @DisplayName("When a request is answered, then it should match present query parameters") + fun `should select response based on query params`(title: String, mapper: Mapper) { + setUpInterceptor(ENABLED, mapper) - val delay = measureTimeMillis { - executeGetRequest("/request").body()?.string() + val param1 = executeGetRequest("/query_param?param=1").body()?.string() + val param2 = executeGetRequest("/query_param?param=2").body()?.string() + + assertEquals("param A", param1) + assertEquals("param B", param2) } - val threshold = 50 - assertTrue(delay >= threshold, "Time was $delay (< $threshold ms)") - } + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") + @DisplayName("When a request is answered, then it should match absent query parameters") + fun `should select response based on absent query params`(title: String, mapper: Mapper) { + setUpInterceptor(ENABLED, mapper) - @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") - fun `should allow to delay responses based on configuration`(title: String, mapper: Mapper) { - setUpInterceptor(ENABLED, mapper) + val param1 = executeGetRequest("/absent_query_param?param1=1").body()?.string() + val param2 = executeGetRequest("/absent_query_param?param1=1¶m2=2") - val delay = measureTimeMillis { - executeGetRequest("/delay").body()?.string() + assertEquals("Body found", param1) + assertEquals(404, param2.code()) } - val noDelay = measureTimeMillis { - executeGetRequest("/request").body()?.string() + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") + @DisplayName("When a request is answered, then it should match present headers") + fun `should select response based on headers`(title: String, mapper: Mapper) { + setUpInterceptor(ENABLED, mapper) + + val noHeaders = executeGetRequest("/headers").body()?.string() + val headers = executeGetRequest( + "/headers", + listOf( + "header1" to "1", + "header1" to "2", + "header2" to "3" + ) + ).body()?.string() + val header1 = executeGetRequest( + "/headers", + listOf("header1" to "1") + ).body()?.string() + val header2 = executeGetRequest( + "/headers", + listOf("header2" to "2") + ).body()?.string() + + assertEquals("no header", noHeaders) + assertEquals("with header 1", header1) + assertEquals("with header 2", header2) + assertEquals("with headers", headers) } - val threshold = 50 - assertTrue(delay >= threshold, "Time was $delay (< $threshold ms)") - assertTrue(noDelay < threshold, "Time without delay was $noDelay (> $threshold ms)") - } + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") + @DisplayName("When a request is answered, then it should match absent headers") + fun `should select response based on absent headers`(title: String, mapper: Mapper) { + setUpInterceptor(ENABLED, mapper) - @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") - fun `should delegate path resolutions`(title: String, mapper: Mapper) { - setUpInterceptor(ENABLED, mapper) + val correctHeader = + executeGetRequest("/absent_header", listOf("header1" to "1")).body()?.string() + val extraHeader = + executeGetRequest("/absent_header", listOf("header1" to "1", "header2" to "2")) - val request = initRequest("/request") - client.newCall(request).execute() + assertEquals("Body found", correctHeader) + assertEquals(404, extraHeader.code()) + } - verify(filingPolicy).getPath(request) - } - @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") - fun `should support mixed mode to execute request when no response is found locally`( - title: String, - mapper: Mapper - ) { - enqueueServerResponse(200, "body") - setUpInterceptor(MIXED, mapper) - - val serverResponse = executeGetRequest("") - val localResponse = executeGetRequest("/request") - - assertResponseCode(serverResponse, 200, "OK") - assertEquals("body", serverResponse.body()?.string()) - assertResponseCode(localResponse, 200, "OK") - assertEquals("simple body", localResponse.body()?.string()) - } + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") + @DisplayName("When a request is answered, then it should match request body") + fun `should select response based on request body`(title: String, mapper: Mapper) { + setUpInterceptor(ENABLED, mapper) - @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") - fun `should allow to stack several interceptors thanks to mixed mode`( - title: String, - mapper: Mapper - ) { - enqueueServerResponse(200, "server response") - - val inMemoryPolicy = InMemoryPolicy(mapper) - inMemoryPolicy.addMatcher( - "$mockServerBaseUrl/inMemory", Matcher( - RequestDescriptor(method = "GET"), - ResponseDescriptor( - code = 200, - body = "in memory response", - mediaType = "text/plain" - ) - ) - ) - val inMemoryInterceptor = MockResponseInterceptor.Builder() - .decodeScenarioPathWith(inMemoryPolicy) - .loadFileWith(inMemoryPolicy::matchRequest) - .parseScenariosWith(mapper) - .setInterceptorStatus(MIXED) - .build() + val match = executeRequest("/body_matching", "POST", "azer1zere").body()?.string() + val noMatch = executeRequest("/body_matching", "POST", "azerzere").body()?.string() - val fileBasedInterceptor = MockResponseInterceptor.Builder() - .decodeScenarioPathWith(filingPolicy) - .loadFileWith(loadingLambda) - .parseScenariosWith(mapper) - .setInterceptorStatus(MIXED) - .build() + assertEquals("matched", match) + assertEquals("no match", noMatch) + } - client = OkHttpClient.Builder() - .addInterceptor(inMemoryInterceptor) - .addInterceptor(fileBasedInterceptor) - .build() - assertEquals("in memory response", executeGetRequest("inMemory").body()?.string()) - assertEquals("file response", executeGetRequest("fileMatch").body()?.string()) - assertEquals("server response", executeGetRequest("serverMatch").body()?.string()) + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") + @DisplayName("When a request with exact match is answered, then match should not allow extra headers or parameters") + fun `should select response based on exact matches`(title: String, mapper: Mapper) { + setUpInterceptor(ENABLED, mapper) + + val exactHeader = executeGetRequest("/exact_match", listOf("header1" to "1")) + val extraHeader = + executeGetRequest("/exact_match", listOf("header1" to "1", "header2" to "2")) + val exactParam = executeGetRequest("/exact_match?param1=1") + val extraParam = executeGetRequest("/exact_match?param1=1¶m2=2") + + assertEquals("Exact headers", exactHeader.body()?.string()) + assertEquals(404, extraHeader.code()) + assertEquals("Exact params", exactParam.body()?.string()) + assertEquals(404, extraParam.code()) + } } - @ParameterizedTest(name = "Mapper: {0}") - @MethodSource("data") - fun `should support dynamic and static mocks together`(title: String, mapper: Mapper) { - val result1 = "Dynamic" - val result2 = "simple body" + @Nested + @DisplayName("Given a mock interceptor in mixed mode") + inner class MixedMode { - interceptor = MockResponseInterceptor.Builder() - .useDynamicMocks { - if (it.url().toString().contains("dynamic")) - ResponseDescriptor(body = result1) - else null - } - .decodeScenarioPathWith(filingPolicy) - .loadFileWith(loadingLambda) - .parseScenariosWith(mapper) - .setInterceptorStatus(ENABLED) - .build() + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") + @DisplayName("When a request is not mocked, then it should go to the server") + fun `should support mixed mode to execute request when no response is found locally`( + title: String, + mapper: Mapper + ) { + enqueueServerResponse(200, "body") + setUpInterceptor(MIXED, mapper) - client = OkHttpClient.Builder().addInterceptor(interceptor).build() + val serverResponse = executeGetRequest("") + val localResponse = executeGetRequest("/request") - val response1 = - client.newCall( - buildRequest( - "http://www.test.fr/dynamic", - method = "GET" - ) - ).execute() - val response2 = - client.newCall( - buildRequest( - "http://www.test.fr/request", - method = "GET" - ) - ).execute() + assertResponseCode(serverResponse, 200, "OK") + assertEquals("body", serverResponse.body()?.string()) + assertResponseCode(localResponse, 200, "OK") + assertEquals("simple body", localResponse.body()?.string()) + } - assertEquals(result1, response1.body()?.string()) - assertEquals(result2, response2.body()?.string()) + @ParameterizedTest(name = "Mapper: {0}") + @MethodSource("fr.speekha.httpmocker.interceptor.TestWithServer#mappers") + @DisplayName("When several interceptors are stacked, then each should delegate to the next one requests it can't answer") + fun `should allow to stack several interceptors thanks to mixed mode`( + title: String, + mapper: Mapper + ) { + enqueueServerResponse(200, "server response") + + val inMemoryPolicy = InMemoryPolicy(mapper) + inMemoryPolicy.addMatcher( + "$mockServerBaseUrl/inMemory", Matcher( + RequestDescriptor(method = "GET"), + ResponseDescriptor( + code = 200, + body = "in memory response", + mediaType = "text/plain" + ) + ) + ) + val inMemoryInterceptor = MockResponseInterceptor.Builder() + .decodeScenarioPathWith(inMemoryPolicy) + .loadFileWith(inMemoryPolicy::matchRequest) + .parseScenariosWith(mapper) + .setInterceptorStatus(MIXED) + .build() + + val fileBasedInterceptor = MockResponseInterceptor.Builder() + .decodeScenarioPathWith(filingPolicy) + .loadFileWith(loadingLambda) + .parseScenariosWith(mapper) + .setInterceptorStatus(MIXED) + .build() + + client = OkHttpClient.Builder() + .addInterceptor(inMemoryInterceptor) + .addInterceptor(fileBasedInterceptor) + .build() + + assertEquals("in memory response", executeGetRequest("inMemory").body()?.string()) + assertEquals("file response", executeGetRequest("fileMatch").body()?.string()) + assertEquals("server response", executeGetRequest("serverMatch").body()?.string()) + } } private fun setUpInterceptor(mode: MockResponseInterceptor.Mode, mapper: Mapper) { @@ -545,9 +586,4 @@ class StaticMockTests : TestWithServer() { client = OkHttpClient.Builder().addInterceptor(interceptor).build() } - - companion object { - @JvmStatic - fun data(): Stream = mappers - } } \ No newline at end of file diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/TestWithServer.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/TestWithServer.kt index c5c858d7..90e751fd 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/TestWithServer.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/interceptor/TestWithServer.kt @@ -114,16 +114,19 @@ open class TestWithServer { body ) } -} -val mappers: Stream - get() = Stream.of( - Arguments.of("Jackson", JacksonMapper()), - Arguments.of("Gson", GsonMapper()), - Arguments.of("Moshi", MoshiMapper()), - Arguments.of("Custom mapper", CustomMapper()), - Arguments.of( - "Kotlinx serialization", - KotlinxMapper(JsonFormatConverter()::import, JsonFormatConverter()::export) - ) - ) + companion object { + + @JvmStatic + fun mappers() : Stream = Stream.of( + Arguments.of("Jackson", JacksonMapper()), + Arguments.of("Gson", GsonMapper()), + Arguments.of("Moshi", MoshiMapper()), + Arguments.of("Custom mapper", CustomMapper()), + Arguments.of( + "Kotlinx serialization", + KotlinxMapper(JsonFormatConverter()::import, JsonFormatConverter()::export) + ) + ) + } +} diff --git a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JsonStringReaderTest.kt b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JsonStringReaderTest.kt index df23d35e..55372243 100644 --- a/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JsonStringReaderTest.kt +++ b/tests/src/test/kotlin/fr/speekha/httpmocker/mappers/JsonStringReaderTest.kt @@ -30,8 +30,6 @@ import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance -import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS import org.junit.jupiter.api.assertThrows import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments @@ -175,8 +173,6 @@ class JsonStringReaderTest { } - - @TestInstance(PER_CLASS) @Nested @DisplayName("Given an object with a String field as input") inner class StringField { @@ -217,17 +213,13 @@ class JsonStringReaderTest { } @ParameterizedTest(name = "Incorrect value: {0}") - @MethodSource("stringErrors") + @MethodSource("fr.speekha.httpmocker.mappers.JsonStringReaderTest#stringErrors") fun `When String is incorrect, an error should occur`(input: String, output: String) { val reader = JsonStringReader(input) val exception = assertThrows { reader.readString() } assertEquals(output, exception.message) } - fun stringErrors(): Stream = listOf( - arrayOf("{a test string}", "$WRONG_START_OF_STRING_FIELD_ERROR{a test..."), - arrayOf("{\"a test string\"}", "$WRONG_START_OF_STRING_FIELD_ERROR{\"a tes...") - ).map { Arguments.of(*it) }.stream() } @Nested @@ -473,5 +465,10 @@ class JsonStringReaderTest { arrayOf("azertyuiopazertyuiol", "azertyu...") ).map { Arguments.of(*it) }.stream() + @JvmStatic + fun stringErrors(): Stream = listOf( + arrayOf("{a test string}", "$WRONG_START_OF_STRING_FIELD_ERROR{a test..."), + arrayOf("{\"a test string\"}", "$WRONG_START_OF_STRING_FIELD_ERROR{\"a tes...") + ).map { Arguments.of(*it) }.stream() } } \ No newline at end of file