├── .github ├── actions │ ├── action-publish-gradle │ │ └── action.yml │ └── android-test │ │ └── action.yml └── workflows │ ├── build.yaml │ └── realease.yml ├── .gitignore ├── CHANGELOG.md ├── LICENCE ├── README.md ├── RELEASING.md ├── build.gradle ├── cucumber-android-hilt ├── build.gradle └── src │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ ├── dagger │ │ │ └── hilt │ │ │ │ └── android │ │ │ │ └── internal │ │ │ │ └── testing │ │ │ │ └── HiltExposer.kt │ │ └── io │ │ │ └── cucumber │ │ │ └── android │ │ │ └── hilt │ │ │ └── HiltObjectFactory.kt │ └── resources │ │ └── META-INF │ │ └── services │ │ └── io.cucumber.core.backend.ObjectFactory │ └── test │ ├── java │ └── io │ │ └── cucumber │ │ └── android │ │ └── hilt │ │ ├── HiltObjectFactoryTest.kt │ │ ├── SomeCucumberHook.kt │ │ ├── SomeDependencies.kt │ │ ├── SomeOtherSteps.kt │ │ ├── SomeSteps.kt │ │ ├── SomeStepsWithoutHilt.kt │ │ └── StepsWithBaseClass.kt │ └── resources │ └── robolectric.properties ├── cucumber-android ├── .gitignore ├── build.gradle └── src │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ ├── io │ │ │ └── cucumber │ │ │ │ ├── android │ │ │ │ ├── AndroidBackend.kt │ │ │ │ ├── AndroidFeatureSupplier.kt │ │ │ │ ├── AndroidLogcatReporter.java │ │ │ │ ├── CucumberAndroidJUnitArguments.java │ │ │ │ ├── CucumberArgumentsProvider.kt │ │ │ │ ├── CucumberJUnitRunnerBuilder.java │ │ │ │ ├── CucumberJunitRunner.kt │ │ │ │ ├── KotlinSnippet.kt │ │ │ │ ├── RulesBackend.kt │ │ │ │ ├── TestClassesScanner.kt │ │ │ │ └── runner │ │ │ │ │ └── CucumberAndroidJUnitRunner.java │ │ │ │ ├── core │ │ │ │ └── runtime │ │ │ │ │ └── CucumberAndroidExecutionContext.kt │ │ │ │ ├── cucumberexpressions │ │ │ │ └── AndroidPatternCompiler.java │ │ │ │ ├── java │ │ │ │ ├── GlueAdaptorWrapper.kt │ │ │ │ └── MethodScannerWrapper.kt │ │ │ │ └── junit │ │ │ │ ├── AndroidFeatureRunner.kt │ │ │ │ ├── AndroidPickleRunner.kt │ │ │ │ ├── CucumberJunitSupport.kt │ │ │ │ └── WithJunitRule.java │ │ └── javax │ │ │ └── lang │ │ │ └── model │ │ │ └── SourceVersion.kt │ └── resources │ │ └── META-INF │ │ └── services │ │ └── io.cucumber.cucumberexpressions.PatternCompiler │ └── test │ ├── assets │ └── features │ │ ├── feature1.feature │ │ └── feature2.feature │ ├── java │ └── io │ │ └── cucumber │ │ ├── android │ │ ├── CucumberAndroidJUnitArgumentsTest.java │ │ ├── CucumberJunitRunnerTest.kt │ │ └── shadows │ │ │ ├── ExtendedShadowPackageManager.kt │ │ │ └── ShadowDexFile.java │ │ └── cucumberexpressions │ │ └── AndroidPatternCompilerTest.java │ └── resources │ └── robolectric.properties ├── cucumber-junit-rules-support ├── build.gradle └── src │ ├── main │ ├── AndroidManifest.xml │ └── java │ │ └── io │ │ └── cucumber │ │ └── junit │ │ ├── TestRuleAccessor.java │ │ ├── TestRulesData.java │ │ └── TestRulesExecutor.java │ └── test │ ├── java │ └── io │ │ └── cucumber │ │ └── junit │ │ └── TestRulesExecutorTest.kt │ └── resources │ └── robolectric.properties ├── cukeulator ├── .gitignore ├── build.gradle └── src │ ├── androidTest │ ├── assets │ │ └── features │ │ │ ├── extra │ │ │ ├── calculate.feature │ │ │ ├── compose.feature │ │ │ └── hilt.feature │ │ │ └── operations │ │ │ ├── addition.feature │ │ │ ├── division.feature │ │ │ ├── multiplication.feature │ │ │ └── subtraction.feature │ └── java │ │ └── cucumber │ │ └── cukeulator │ │ └── test │ │ ├── ActivityScenarioHolder.kt │ │ ├── BaseKotlinSteps.kt │ │ ├── CalculatorActivitySteps.java │ │ ├── ComposeRuleHolder.kt │ │ ├── CukeulatorAndroidJUnitRunner.java │ │ ├── CustomComposableSteps.kt │ │ ├── FakeHiltModule.kt │ │ ├── InstrumentationNonCucumberTest.java │ │ ├── KotlinSteps.kt │ │ ├── SomeClassWithUnsupportedApi.java │ │ ├── SomeDependency.java │ │ └── TypeRegistryConfiguration.java │ ├── debug │ └── AndroidManifest.xml │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── cucumber │ │ └── cukeulator │ │ ├── CalculatorActivity.java │ │ ├── ComposeTestActivity.kt │ │ ├── CukeulatorApplication.kt │ │ ├── GreetingService.kt │ │ └── HiltModule.kt │ └── res │ ├── drawable-hdpi │ └── ic_launcher.png │ ├── drawable-mdpi │ └── ic_launcher.png │ ├── drawable-xhdpi │ └── ic_launcher.png │ ├── drawable-xxhdpi │ └── ic_launcher.png │ ├── layout │ └── activity_calculator.xml │ ├── menu │ └── menu_main.xml │ └── values │ ├── dimens.xml │ ├── strings.xml │ └── styles.xml ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle /.github/actions/action-publish-gradle/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Publish to Sonatype Nexus' 2 | description: 'Publish artifacts' 3 | inputs: 4 | gpg-private-key: 5 | description: GPG private key for signing the published artifacts 6 | required: true 7 | gpg-passphrase: 8 | description: Passphrase for the GPG key 9 | required: true 10 | nexus-username: 11 | description: Username (not email!) for your Nexus repository manager account 12 | required: true 13 | nexus-password: 14 | description: Password for your Nexus account 15 | required: true 16 | gradle-tasks: 17 | description: Gradle tasks to run 18 | required: false 19 | default: publishToSonatype closeAndReleaseSonatypeStagingRepository 20 | 21 | runs: 22 | using: "composite" 23 | steps: 24 | - name: publish artifacts 25 | run: | 26 | ./gradlew ${{ inputs.gradle-tasks }} 27 | shell: bash 28 | env: 29 | ORG_GRADLE_PROJECT_nexusUsername: ${{ inputs.nexus-username }} 30 | ORG_GRADLE_PROJECT_nexusPassword: ${{ inputs.nexus-password }} 31 | ORG_GRADLE_PROJECT_signingPassword: ${{ inputs.gpg-passphrase }} 32 | ORG_GRADLE_PROJECT_signingKey: ${{ inputs.gpg-private-key }} 33 | 34 | branding: 35 | icon: package 36 | color: green -------------------------------------------------------------------------------- /.github/actions/android-test/action.yml: -------------------------------------------------------------------------------- 1 | name: Run android tests 2 | description: Runs cukeulator android tests 3 | 4 | runs: 5 | using: composite 6 | steps: 7 | - name: run tests 8 | uses: reactivecircus/android-emulator-runner@v2.32.0 9 | with: 10 | api-level: 30 11 | emulator-options: -skin 1920x1080 -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none 12 | arch: x86_64 13 | script: ./gradlew :cukeulator:connectedCheck -PdisableAnimations=true 14 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | # Trigger on every pull request and on push 4 | # to the `main` branch. 5 | pull_request: 6 | push: 7 | branches: 8 | - main 9 | 10 | jobs: 11 | build: 12 | runs-on: macos-13 13 | steps: 14 | - uses: actions/setup-java@v3 15 | with: 16 | distribution: 'temurin' 17 | java-version: '17' 18 | - name: Checkout the code 19 | uses: actions/checkout@v4 20 | - name: Build the app 21 | run: ./gradlew build --stacktrace -x lint 22 | - name: run tests 23 | uses: ./.github/actions/android-test 24 | 25 | -------------------------------------------------------------------------------- /.github/workflows/realease.yml: -------------------------------------------------------------------------------- 1 | name: Release cucumber-android 2 | 3 | on: 4 | push: 5 | branches: 6 | - release/* 7 | 8 | jobs: 9 | publish: 10 | name: Publish cucumber-android 11 | runs-on: macos-13 12 | environment: Release 13 | 14 | steps: 15 | - uses: actions/setup-java@v3 16 | with: 17 | distribution: 'temurin' 18 | java-version: '17' 19 | - uses: actions/checkout@v2 20 | - name: Check commit has been pushed on origin/main 21 | run: | 22 | git fetch --quiet origin main 23 | git merge-base --is-ancestor HEAD origin/main 24 | - name: Build the app 25 | run: ./gradlew build --stacktrace -x lint 26 | - name: run tests 27 | uses: ./.github/actions/android-test 28 | - name: Publish 29 | uses: ./.github/actions/action-publish-gradle 30 | with: 31 | gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} 32 | gpg-passphrase: ${{ secrets.GPG_PASSPHRASE }} 33 | nexus-username: ${{ secrets.SONATYPE_USERNAME }} 34 | nexus-password: ${{ secrets.SONATYPE_PASSWORD }} 35 | 36 | create-github-release: 37 | name: Create GitHub Release and Git tag 38 | needs: publish 39 | runs-on: ubuntu-latest 40 | environment: Release 41 | permissions: 42 | contents: write 43 | 44 | steps: 45 | - uses: actions/checkout@v2 46 | - uses: cucumber/action-create-github-release@v1.1.1 47 | with: 48 | github-token: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | .idea 3 | *.iml 4 | local.properties 5 | .gradle 6 | build 7 | /cucumber-android/build/ 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/). 7 | 8 | ## [Unreleased] 9 | 10 | ## [7.18.1] - 2024-07-25 11 | ### Changed 12 | - handle multiple classes (features) and methods (scenarios) specified in `class` argument to better align with tools which requests specific scenarios to be executed 13 | - support fields injection in steps classes with Hilt 14 | - fix [#131](https://github.com/cucumber/cucumber-android/issues/131) 15 | - update `cucumber-jvm` dependencies to `7.18.1` 16 | - update Kotlin to `2.0.0` 17 | 18 | ## 7.14.0 - 2023-09-25 19 | ### Added 20 | - tag expression support in `@WithJunitRule` to execute particular rules in specific scenarios only 21 | 22 | ### Changed 23 | - update `cucumber-jvm` dependencies to `7.14.0`. From now `cucumber-android` requires Java 8 api and Android API >= 26 or [desugaring](https://developer.android.com/studio/write/java8-support-table). For changes in behaviour of `cucumber-jvm` check https://github.com/cucumber/cucumber-jvm/tree/main/release-notes. 24 | - target android sdk version `34` 25 | - rewritten most of classes to [Kotlin](https://kotlinlang.org/) and moved to package `io.cucumber.android` 26 | - removed support for some of the arguments passed by instrumentation, check `CucumberAndroidJUnitArguments.PublicArgs` method for supported properties 27 | 28 | ### Fixed 29 | - [#118 @WithJunitRule executes rule for every scenario](https://github.com/cucumber/cucumber-android/issues/118) 30 | - [#102 Testing reusable composables without activity not possible](https://github.com/cucumber/cucumber-android/issues/102) 31 | 32 | ## [4.10.0] - 2023-02-17 33 | ### Added 34 | - add `HiltObjectFactory` 35 | 36 | ### Fixed 37 | - [#111 Hilt gives me "Called inject() multiple times" exception](https://github.com/cucumber/cucumber-android/issues/111) 38 | 39 | ## [4.9.0] - 2021-11-16 40 | ### Added 41 | - add support for Junit rules 42 | 43 | ### Fixed 44 | - [#87 Jetpack Compose support](https://github.com/cucumber/cucumber-android/issues/87) 45 | - [#81 Hilt support](https://github.com/cucumber/cucumber-android/issues/81) 46 | 47 | ## 4.8.2 48 | ### Fixed 49 | - exclude kotlin generated classes for inlined functions from glue scanning 50 | - does not fail if `Class.getMethods()` throws `NoClassDefFoundError` 51 | 52 | ## 4.8.1 53 | ### Changed 54 | - upgrade `cucumber-core` to `4.8.1` 55 | 56 | ## 4.7.4 57 | ### Changed 58 | - upgrade `cucumber-core` to `4.7.4` 59 | - extract the 'create backend supplier' method into the factory. 60 | 61 | ## 4.6.0 62 | ### Changed 63 | - upgrade `cucumber-core` to `4.6.0` 64 | 65 | ## 4.5.4 66 | ### Changed 67 | - upgrade `cucumber-core` to `4.5.4` 68 | - upgrade package names to match JVM project package names. 69 | 70 | ## 4.4.1 71 | ### Changed 72 | - [#43](https://github.com/cucumber/cucumber-android/issues/43) resolved by PR [#39](https://github.com/cucumber/cucumber-android/pull/39) - (Roman Havran) 73 | - option to run regular android junit tests with `CucumberAndroidJUnitRunner` 74 | 75 | ## 4.4.0 76 | ### Changed 77 | - upgrade `cucumber-core` to `4.4.0` 78 | - upgrade `junit` to `4.13` 79 | 80 | ## 4.3.1 81 | ### Changed 82 | - upgrade `cucumber-core` to `4.3.1` 83 | - properly create `JUnitOptions` to respect strict setting 84 | 85 | ## 4.3.0 86 | ### Changed 87 | - upgrade `cucumber-core` to `4.3.0` 88 | 89 | ## 4.2.5 90 | ### Fixed 91 | - [#17](https://github.com/cucumber/cucumber-android/pull/17) - reports & rerun require TestRunFinishedEvent to be posted (kaskasi) 92 | 93 | ## 4.2.4 94 | ### Changed 95 | - From PR [#14](https://github.com/cucumber/cucumber-android/pull/14) (Viacheslav Iankovyi, Łukasz Suski) 96 | - set target sdk to `28` 97 | - migrate to `androidx` and `AndroidJunitRunner` 98 | - add support for Android Test Orchestrator and spoon sharding 99 | - ensure uniqueness of `#` 100 | - each scenario outline example receives continues number starting from 1 101 | - if duplicate feature name or scenario in single feature is detected then error is thrown 102 | 103 | ### Fixed 104 | - [#2](https://github.com/cucumber/cucumber-android/issues/2) - cucumber-android does not integrate very well with Android Orchestrator 105 | 106 | ## 4.2.2 107 | ### Changed 108 | - upgrade cucumber-java to `4.2.2` 109 | 110 | ## 4.0.0 111 | ### Changed 112 | - migrate everything to Gradle 113 | 114 | ### Fixed 115 | - [#5](https://github.com/cucumber/cucumber-android/issues/5) - Sample Does Not Work 116 | - [#4](https://github.com/cucumber/cucumber-android/issues/4) - Support for parallel cukes 117 | - [#3](https://github.com/cucumber/cucumber-android/issues/3) - Reported duration time of scenario is about 0ms on Android 118 | 119 | ### Removed 120 | - android-studio sample - now [cukeulator](https://github.com/cucumber/cucumber-android/tree/master/cukeulator) is the only valid sample (for Gradle and Android Studio) 121 | - cukeulator-test and cucumber-android-test 122 | 123 | [Unreleased]: https://github.com/cucumber/cucumber-jvm/compare/v7.18.1...HEAD 124 | [7.18.1]: https://github.com/cucumber/cucumber-jvm/compare/v7.18.0...v7.18.1 125 | [4.10.0]: https://github.com/cucumber/cucumber-jvm/compare/v4.10.0...v7.18.0 126 | [4.9.0]: https://github.com/cucumber/cucumber-jvm/compare/v4.9.0...v4.10.0 127 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | Copyright (c) The Cucumber Organisation 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build](https://github.com/cucumber/cucumber-android/actions/workflows/build.yaml/badge.svg)](https://github.com/cucumber/cucumber-android/actions/workflows/build.yaml) 2 | 3 | # Cucumber-Android 4 | 5 | This project implements Android support for Cucumber-JVM. It allows 6 | running cucumber tests with Android Test Orchestrator and using 7 | sharding. 8 | 9 | NOTE: Although minSdkVersion for `cucumber-android` is 14 it requires 10 | Java 8 language features and minimum Android API level 26. This is done 11 | purposely to allow using cucumber in apps with lower minSdk (to avoid 12 | compile errors) but tests should be run on devices with API >= 26. 13 | However with desugaring enabled it may work in some configurations on lower API levels assuming that desugaring covers all the Java 8 api. 14 | Not all features from `cucumber-jvm` are supported in `cucumber-android` due to differences in Android vs JDK (especially junit and html plugins which requires xml factory classes not available in Android) 15 | 16 | ## Developers 17 | 18 | ### Prerequisites 19 | 20 | This is ordinary multimodule Android project 21 | 22 | * `cucumber-android` - main library 23 | * `cucumber-android-hilt` - Hilt object factory 24 | * `cucumber-junit-rules-support` - internal module for Junit rules support 25 | * `cukeulator` - sample application with instrumented tests 26 | 27 | ### Building 28 | 29 | ```sh 30 | ./gradlew assemble 31 | ``` 32 | 33 | ### Setting up the dependency 34 | 35 | The first step is to include cucumber-android into your project, for example, as a Gradle androidTestImplementation dependency: 36 | 37 | ```groovy 38 | androidTestImplementation "io.cucumber:cucumber-android:$cucumberVersion" 39 | ``` 40 | 41 | ### Using Cucumber-Android 42 | 43 | 1. Create a class in your testApplicationId package (usually it's a `namespace` from `build.gradle` with `.test` suffix) and add `@CucumberOptions` annotation to that class. You can also put such class in different package or have many such classes in different packages but then you have to provide path to it in instrumentation argument `optionsAnnotationPackage`. 44 | 45 | Gradle example: 46 | ```groovy 47 | android { 48 | defaultConfig { 49 | testInstrumentationRunner "io.cucumber.android.runner.CucumberAndroidJUnitRunner" 50 | testInstrumentationRunnerArguments(optionsAnnotationPackage: "some.other.package") 51 | } 52 | } 53 | ``` 54 | 55 | Commandline example: 56 | ``` 57 | adb shell am instrument -w -e optionsAnnotationPackage some.other.package com.mycompany.app.test/com.mycompany.app.test.MyTests 58 | ``` 59 | 60 | This class doesn't need to have anything in it, but you can also put some codes in it if you want. The purpose of doing this is to provide cucumber options. A simple example can be found in `cukeulator`. Or a more complicated example here: 61 | ```java 62 | package com.mycompany.app.test; 63 | 64 | @CucumberOptions(glue = { "com.mytest.steps" }, tags = "~@wip" , features = { "features" }) 65 | public class MyTests 66 | { 67 | } 68 | ``` 69 | glue is the list of packages which contain step definitions classes and also classes annotated with `@WithJunitRule`, tags is the tags placed above scenarios titles you want cucumber-android to run or not run, features is the path to the feature files in android test assets directory. 70 | 71 | 2. Write your .feature files under your project's android test `assets/` folder. If you specify `features = "features"` in `@CucumberOptions` like the example above then it's `androidTest/assets/features` (might be also `androidTest/assets/features`). 72 | 73 | 3. Write your step definitions under the package name specified in glue. For example, if you specified `glue = ["com.mytest.steps"]`, then create a new package under your `androidTest/java` (or `androidTest/kotlin`) named `com.mytest.steps` and put your step definitions under it. Note that all subpackages will also be included, so you can also put in `com.mytest.steps.mycomponent`. 74 | 75 | 4. Set instrumentation runner to `io.cucumber.android.runner.CucumberAndroidJUnitRunner` or class that extends it 76 | ```groovy 77 | android.defaultConfig.testInstrumentationRunner "io.cucumber.android.runner.CucumberAndroidJUnitRunner" 78 | ``` 79 | 80 | If needed you can specify some cucumber options using instrumentation arguments. Check available options in `io.cucumber.android.CucumberAndroidJUnitArguments.PublicArgs` class 81 | 82 | For example to specify glue package use: 83 | ```groovy 84 | android.defaultConfig.testInstrumentationRunnerArguments(glue: "com.mytest.steps") 85 | ``` 86 | 87 | ### Debugging 88 | Please read [the Android documentation on debugging](https://developer.android.com/tools/debugging/index.html). 89 | 90 | ### Examples 91 | 92 | Currently there is one example in subproject [cukeulator](https://github.com/cucumber/cucumber-android/tree/master/cukeulator) 93 | 94 | To create a virtual device and start an [Android emulator](https://developer.android.com/tools/devices/index.html): 95 | 96 | ``` 97 | $ANDROID_HOME/tools/android avd 98 | ``` 99 | 100 | ### Junit rules support 101 | 102 | Experimental support for Junit rules was added in version `4.9.0`. 103 | Cucumber works differently than junit - you cannot just add rule to some steps class 104 | because during scenario execution many such steps classes can be instantiated. 105 | Cucumber has its own Before/After mechanism. If you have just 1 steps class then this could work 106 | If you have many steps classes then it is better to separate rule and `@Before/@After` hooks 107 | from steps classes 108 | 109 | To let Cucumber discover that particular class has rules add 110 | ``` 111 | @WithJunitRule 112 | class ClassWithRules { 113 | ... 114 | } 115 | ``` 116 | 117 | and put this class in glue package. Glue packages are specified in `@CucumberOptions` annotation, see [Using Cucumber-Android](#using-cucumber-android) 118 | 119 | You can specify tag expression like `@WithJunitRule("@MyTag")` to control for which scenarios this rule should be executed. See `compose.feature` and `ComposeRuleHolder` for example 120 | 121 | 122 | ### Sharding and running with Android Test Orchestrator 123 | 124 | `CucumberAndroidJUnitRunner` works with Android Test Orchestrator and sharding because it reports tests and classes as feature names and scenario names like `My feature#My scenario` and is able to parse `-e class` argument from instrumentation. 125 | It also supports multiple names in `-e class` argument separated by comma. This means that feature and scenario name cannot have comma in it's name because it is reserved for separating multiple names (only if you want to use Orchestrator or in general `class` argument, for other use cases comma is allowed). 126 | 127 | 128 | ### Jetpack Compose rule 129 | 130 | ``` 131 | @WithJunitRule 132 | class ComposeRuleHolder { 133 | 134 | @get:Rule 135 | val composeRule = createEmptyComposeRule() 136 | } 137 | ``` 138 | 139 | then inject this object in steps, e.g. 140 | (can be also inject as `lateinit var` field (depending on injection framework used) 141 | 142 | ``` 143 | class KotlinSteps(val composeRuleHolder: ComposeRuleHolder, val scenarioHolder: ActivityScenarioHolder):SemanticsNodeInteractionsProvider by composeRuleHolder.composeRule { 144 | 145 | ... 146 | 147 | @Then("^\"([^\"]*)\" text is presented$") 148 | fun textIsPresented(arg0: String) { 149 | onNodeWithText(arg0).assertIsDisplayed() 150 | } 151 | } 152 | ``` 153 | 154 | Check [Junit rules support](#junit-rules-support) for more information of adding classes with JUnit rules 155 | 156 | ### Hilt 157 | 158 | There are 2 solutions for using Hilt with Cucumber: 159 | 160 | ##### 1. HiltObjectFactory 161 | 162 | Add dependency: 163 | ```groovy 164 | androidTestImplementation "io.cucumber:cucumber-android-hilt:$cucumberVersion" 165 | ``` 166 | 167 | Don't use any other dependency with `ObjectFactory` like `cucumber-picocontainer` 168 | 169 | `HiltObjectFactory` will be automatically used as `ObjectFactory`. 170 | 171 | To inject object managed by Hilt into steps or hook or any other class managed by Cucumber: 172 | 173 | ```kotlin 174 | @HiltAndroidTest 175 | class KotlinSteps( 176 | val composeRuleHolder: ComposeRuleHolder, 177 | val scenarioHolder: ActivityScenarioHolder 178 | ):SemanticsNodeInteractionsProvider by composeRuleHolder.composeRule { 179 | 180 | @Inject 181 | lateinit var greetingService:GreetingService 182 | 183 | @Then("I should see {string} on the display") 184 | fun I_should_see_s_on_the_display(s: String?) { 185 | Espresso.onView(withId(R.id.txt_calc_display)).check(ViewAssertions.matches(ViewMatchers.withText(s))) 186 | } 187 | 188 | } 189 | ``` 190 | 191 | Such class: 192 | - must have `@HiltAndroidTest` annotation to let Hilt generate injecting code 193 | - can have Cucumber managed objects like hooks injected in constructor 194 | - can have Cucumber managed objects injected in fields but such objects have to be annotated with `@Singleton` annotation and constructor has to be annotated with `@Inject` annotation 195 | - can have Hilt managed objects injected using field injection or constructor 196 | - can have objects injected in base class 197 | 198 | Also: 199 | after each scenario Hilt will clear all objects and create new ones (even these marked as @Singleton) (like it does for each test class in Junit) 200 | 201 | ##### 2. @WithJunitRule 202 | 203 | 204 | Hilt requires to have rule in actual test class (which for cucumber is impossible 205 | because there is no such class). To workaround that: 206 | 207 | See https://developer.android.com/training/dependency-injection/hilt-testing#multiple-testrules 208 | how to use hilt with other rules (like compose rule) 209 | 210 | ``` 211 | @WithJunitRule(useAsTestClassInDescription = true) 212 | @HiltAndroidTest 213 | class HiltRuleHolder { 214 | 215 | @Rule(order = 0) 216 | @JvmField 217 | val hiltRule = HiltAndroidRule(this) 218 | 219 | //if you need it to be injected 220 | @Inject 221 | lateinit var greetingService: GreetingService 222 | 223 | @Before 224 | fun init() { 225 | //if you have anything to inject here and/or used elsewhere in tests 226 | hiltRule.inject() 227 | } 228 | 229 | } 230 | ``` 231 | 232 | then you can inject such class to steps class using Cucumber dependency injector (like picocontainer) 233 | 234 | 235 | 236 | ### Running scenarios from IDE 237 | 238 | There is third-party plugin (not related with Cucumber organisation and this repository) which allows running scenarios directly from Android Studio or Intellij 239 | 240 | [Cucumber for Kotlin and Android](https://plugins.jetbrains.com/plugin/22107-cucumber-for-kotlin-and-android) 241 | 242 | 243 | ## Troubleshooting 244 | 245 | 1. Compose tests fails 246 | 247 | `java.lang.IllegalStateException: Test not setup properly. Use a ComposeTestRule in your test to be able to interact with composables` 248 | 249 | ##### Solution 250 | 251 | Check [Jetpack Compose rule](#jetpack-compose-rule) section. Make sure that your class with `@WithJunitRule` annotation is placed in glue package as described in [Using Cucumber-Android](#using-cucumber-android) -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Release process for cucumber-android 2 | 3 | ## Prerequisites 4 | 5 | To release `cucumber-android`, you'll need to be a member of the core team 6 | 7 | ## Releasing cucumber-android 8 | 9 | - Remove `-SNAPSHOT` in `build.gradle` `version =` entry 10 | - Update `CHANGELOG.md` with the upcoming version number and create a new `In Git` section 11 | - Remove empty sections from `CHANGELOG.md` 12 | - Commit the changes preferably using a verified signature, and push to main branch 13 | ```shell 14 | git commit --gpg-sign -am "Release X.Y.Z" 15 | git push 16 | ``` 17 | - To trigger the release process, `git push` to a dedicated `release/` branch: 18 | ```shell 19 | git push origin main:release/vX.Y.Z 20 | ``` 21 | - Monitor the [github workflow](https://github.com/cucumber/cucumber-android/actions/workflows/release.yml) 22 | - Check the release has been successfully pushed to [Maven Central](https://search.maven.org/artifact/io.cucumber/cucumber-android) -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | import java.time.Duration 2 | 3 | buildscript { 4 | ext.kotlin_version = '2.0.0' 5 | ext.ksp_version = "$kotlin_version-1.0.23" 6 | ext.hilt_version = '2.51.1' 7 | repositories { 8 | google() 9 | mavenCentral() 10 | mavenLocal() 11 | maven { url "https://oss.sonatype.org/content/repositories/snapshots" } 12 | maven { url 'https://jitpack.io' } 13 | maven { url = uri("https://plugins.gradle.org/m2/") } 14 | } 15 | dependencies { 16 | classpath 'com.android.tools.build:gradle:8.5.1' 17 | classpath "io.github.gradle-nexus:publish-plugin:2.0.0" 18 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 19 | classpath "org.jetbrains.kotlin:compose-compiler-gradle-plugin:$kotlin_version" 20 | classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version" 21 | classpath "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin:$ksp_version" 22 | } 23 | } 24 | 25 | apply plugin: "io.github.gradle-nexus.publish-plugin" 26 | 27 | 28 | ext { 29 | targetSdkVersion = 34 30 | buildToolsVersion = '34.0.0' 31 | minSdkVersion = '14' 32 | 33 | robolectricVersion = '4.11.1' 34 | 35 | snapshotRepository = "https://oss.sonatype.org/content/repositories/snapshots" 36 | releaseRepository = "https://oss.sonatype.org/service/local/staging/deploy/maven2/" 37 | 38 | isSnapshot = { project.version.endsWith('-SNAPSHOT') } 39 | } 40 | 41 | 42 | allprojects { 43 | repositories { 44 | mavenLocal() 45 | google() 46 | mavenCentral() 47 | maven { 48 | url snapshotRepository 49 | } 50 | maven { 51 | url releaseRepository 52 | } 53 | } 54 | version = "7.19.0-SNAPSHOT" 55 | ext.cucumber_javaVersion = '7.18.1' 56 | group = 'io.cucumber' 57 | } 58 | 59 | 60 | nexusPublishing { 61 | repositories { 62 | sonatype { 63 | username = findProperty("nexusUsername") 64 | password = findProperty("nexusPassword") 65 | } 66 | } 67 | connectTimeout = Duration.ofMinutes(5) 68 | clientTimeout = Duration.ofMinutes(5) 69 | } 70 | 71 | 72 | subprojects { subproject -> 73 | 74 | 75 | ext.addAndroidConfig = { 76 | android { 77 | compileSdkVersion rootProject.ext.targetSdkVersion 78 | buildToolsVersion rootProject.ext.buildToolsVersion 79 | 80 | defaultConfig { 81 | minSdkVersion rootProject.ext.minSdkVersion 82 | targetSdkVersion rootProject.ext.targetSdkVersion 83 | } 84 | 85 | compileOptions { 86 | sourceCompatibility JavaVersion.VERSION_1_8 87 | targetCompatibility JavaVersion.VERSION_1_8 88 | } 89 | 90 | lintOptions { 91 | abortOnError false 92 | } 93 | 94 | testOptions { 95 | unitTests { 96 | includeAndroidResources = true 97 | } 98 | } 99 | kotlinOptions { 100 | jvmTarget = "1.8" 101 | } 102 | } 103 | 104 | } 105 | 106 | ext.addLibraryPublishing = { pomName -> 107 | 108 | apply plugin: 'signing' 109 | apply plugin: 'maven-publish' 110 | android { 111 | namespace = "io.cucumber.android.${subproject.name.replace("-", "_")}" 112 | 113 | publishing { 114 | singleVariant('release') { 115 | withSourcesJar() 116 | withJavadocJar() 117 | } 118 | } 119 | 120 | } 121 | 122 | afterEvaluate { 123 | 124 | publishing { 125 | publications { 126 | release(MavenPublication) { 127 | from components.release 128 | 129 | pom { 130 | name = pomName 131 | packaging = 'aar' 132 | // optionally artifactId can be defined here 133 | description = 'Android support for Cucumber-JVM' 134 | url = 'https://github.com/cucumber/cucumber-android' 135 | 136 | scm { 137 | connection = 'scm:git:https://github.com/cucumber/cucumber-android.git' 138 | developerConnection = 'scm:git:git@github.com:cucumber/cucumber-android.git' 139 | url = 'https://github.com/cucumber/cucumber-android' 140 | } 141 | 142 | licenses { 143 | license { 144 | name = 'MIT License' 145 | url = 'http://www.opensource.org/licenses/mit-license' 146 | } 147 | } 148 | 149 | developers { 150 | developer { 151 | id = 'lsuski' 152 | name = 'Łukasz Suski' 153 | email = 'lukasz.suski@finanteq.com' 154 | } 155 | } 156 | } 157 | } 158 | } 159 | } 160 | if (findProperty("cucumber.disableSigning") != "true") { 161 | 162 | signing { 163 | def signingKey = findProperty("signingKey") 164 | def signingPassword = findProperty("signingPassword") 165 | useInMemoryPgpKeys(signingKey, signingPassword) 166 | sign publishing.publications.release 167 | } 168 | } 169 | } 170 | 171 | 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /cucumber-android-hilt/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'com.google.devtools.ksp' 4 | apply plugin: 'dagger.hilt.android.plugin' 5 | 6 | addAndroidConfig() 7 | addLibraryPublishing('Cucumber-JVM: Android Hilt') 8 | 9 | dependencies { 10 | api project(':cucumber-android') 11 | implementation project(':cucumber-junit-rules-support') 12 | api "com.google.dagger:hilt-android:$hilt_version" 13 | api "com.google.dagger:hilt-android-testing:$hilt_version" 14 | implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" 15 | ksp "com.google.dagger:hilt-android-compiler:$hilt_version" 16 | 17 | testImplementation "org.robolectric:robolectric:$robolectricVersion" 18 | kspTest "com.google.dagger:hilt-android-compiler:$hilt_version" 19 | 20 | } 21 | 22 | -------------------------------------------------------------------------------- /cucumber-android-hilt/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /cucumber-android-hilt/src/main/java/dagger/hilt/android/internal/testing/HiltExposer.kt: -------------------------------------------------------------------------------- 1 | package dagger.hilt.android.internal.testing 2 | 3 | internal object HiltExposer { 4 | 5 | 6 | fun getTestComponentData(clazz:Class<*>): TestComponentData? { 7 | return runCatching { TestComponentDataSupplier.get(clazz) }.getOrNull() 8 | } 9 | } -------------------------------------------------------------------------------- /cucumber-android-hilt/src/main/java/io/cucumber/android/hilt/HiltObjectFactory.kt: -------------------------------------------------------------------------------- 1 | package io.cucumber.android.hilt 2 | 3 | import android.app.Application 4 | import androidx.test.core.app.ApplicationProvider 5 | import dagger.hilt.android.internal.testing.HiltExposer 6 | import dagger.hilt.android.testing.HiltAndroidRule 7 | import dagger.hilt.android.testing.HiltAndroidTest 8 | import dagger.hilt.internal.GeneratedComponentManager 9 | import io.cucumber.core.backend.ObjectFactory 10 | import io.cucumber.junit.TestRuleAccessor 11 | import io.cucumber.junit.TestRulesData 12 | import io.cucumber.junit.TestRulesExecutor 13 | import org.junit.rules.TestRule 14 | import org.junit.runner.Description 15 | import java.util.Locale 16 | import java.util.concurrent.Executors 17 | import javax.inject.Provider 18 | 19 | @HiltAndroidTest 20 | class HiltObjectFactory : ObjectFactory { 21 | 22 | private val executor = Executors.newSingleThreadExecutor() 23 | private lateinit var rulesExecutor: TestRulesExecutor 24 | private val testDescription = Description.createTestDescription(javaClass, "start") 25 | 26 | private val objects = hashMapOf, Any?>() 27 | 28 | override fun start() { 29 | 30 | rulesExecutor = TestRulesExecutor(listOf(TestRulesData(false, this, listOf(ruleAccessor()), "")), executor) 31 | 32 | rulesExecutor.startRules(testDescription, emptyList()) 33 | } 34 | 35 | private fun ruleAccessor(): TestRuleAccessor { 36 | val hiltRule = HiltAndroidRule(this) 37 | return object : TestRuleAccessor { 38 | override fun getRule(obj: Any?): TestRule = hiltRule 39 | 40 | override fun getOrder(): Int = 0 41 | } 42 | } 43 | 44 | override fun stop() { 45 | rulesExecutor.stopRules() 46 | objects.clear() 47 | } 48 | 49 | override fun addClass(glueClass: Class<*>?): Boolean = true 50 | 51 | override fun getInstance(glueClass: Class): T { 52 | @Suppress("UNCHECKED_CAST") 53 | return objects.getOrPut(glueClass) { 54 | val instance = createInstance(glueClass) 55 | injectWithHilt(glueClass, instance) 56 | instance 57 | } as T 58 | } 59 | 60 | private fun injectWithHilt(glueClass: Class, instance: T) { 61 | HiltExposer.getTestComponentData(glueClass)?.testInjector()?.injectTest(instance) 62 | } 63 | 64 | private fun createInstance(glueClass: Class): T { 65 | return tryFindProviderInHiltComponent(glueClass) ?: createInstanceUsingConstructor(glueClass) 66 | } 67 | 68 | private fun createInstanceUsingConstructor(glueClass: Class) = glueClass.declaredConstructors.single().let { constructor -> 69 | @Suppress("UNCHECKED_CAST") 70 | constructor.newInstance(*constructor.parameterTypes.map { getInstance(it) }.toTypedArray()) as T 71 | } 72 | 73 | 74 | @Suppress("UNCHECKED_CAST") 75 | private fun tryFindProviderInHiltComponent(glueClass: Class): T? { 76 | val component = (ApplicationProvider.getApplicationContext() as GeneratedComponentManager).generatedComponent() 77 | return component.javaClass.declaredFields.find { it.name == "${glueClass.simpleName.replaceFirstChar { it.lowercase(Locale.ROOT) }}Provider" }?.let { 78 | it.isAccessible = true 79 | it.get(component) as Provider 80 | }?.get() 81 | } 82 | } -------------------------------------------------------------------------------- /cucumber-android-hilt/src/main/resources/META-INF/services/io.cucumber.core.backend.ObjectFactory: -------------------------------------------------------------------------------- 1 | io.cucumber.android.hilt.HiltObjectFactory -------------------------------------------------------------------------------- /cucumber-android-hilt/src/test/java/io/cucumber/android/hilt/HiltObjectFactoryTest.kt: -------------------------------------------------------------------------------- 1 | package io.cucumber.android.hilt 2 | 3 | import dagger.hilt.android.testing.HiltAndroidTest 4 | import dagger.hilt.android.testing.HiltTestApplication 5 | import org.junit.Assert.* 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | import org.robolectric.RobolectricTestRunner 9 | import org.robolectric.annotation.Config 10 | 11 | @HiltAndroidTest 12 | @RunWith(RobolectricTestRunner::class) 13 | @Config(application = HiltTestApplication::class) 14 | class HiltObjectFactoryTest { 15 | 16 | private var hiltObjectFactory = HiltObjectFactory() 17 | 18 | @Test 19 | fun `add class returns true`() { 20 | assertTrue(hiltObjectFactory.addClass(String::class.java)) 21 | } 22 | 23 | @Test 24 | fun `injects into fields with proper scope`() { 25 | 26 | hiltObjectFactory.start() 27 | 28 | val someSteps = hiltObjectFactory.getInstance(SomeSteps::class.java) 29 | val someOtherSteps = hiltObjectFactory.getInstance(SomeOtherSteps::class.java) 30 | 31 | someSteps.doSomething() 32 | someOtherSteps.doSomething() 33 | 34 | assertSame(someOtherSteps.someSingletonDependency,someSteps.someSingletonDependency) 35 | assertNotSame(someOtherSteps.someDependency,someSteps.someDependency) 36 | 37 | hiltObjectFactory.stop() 38 | } 39 | 40 | @Test 41 | fun `returns the same instance of steps`() { 42 | 43 | hiltObjectFactory.start() 44 | 45 | val someSteps1 = hiltObjectFactory.getInstance(SomeSteps::class.java) 46 | val someSteps2 = hiltObjectFactory.getInstance(SomeSteps::class.java) 47 | 48 | 49 | assertSame(someSteps1,someSteps2) 50 | assertSame(someSteps1.someSingletonDependency,someSteps2.someSingletonDependency) 51 | assertSame(someSteps1.someDependency,someSteps2.someDependency) 52 | 53 | hiltObjectFactory.stop() 54 | } 55 | 56 | @Test 57 | fun `returns new instance for second scenario`() { 58 | 59 | hiltObjectFactory.start() 60 | 61 | val someSteps1 = hiltObjectFactory.getInstance(SomeSteps::class.java) 62 | 63 | hiltObjectFactory.stop() 64 | hiltObjectFactory.start() 65 | 66 | val someSteps2 = hiltObjectFactory.getInstance(SomeSteps::class.java) 67 | 68 | 69 | assertNotSame(someSteps1,someSteps2) 70 | assertNotSame(someSteps1.someSingletonDependency,someSteps2.someSingletonDependency) 71 | 72 | hiltObjectFactory.stop() 73 | 74 | } 75 | 76 | @Test 77 | fun `creates instance without hilt dependencies`() { 78 | hiltObjectFactory.start() 79 | 80 | val someSteps1 = hiltObjectFactory.getInstance(SomeStepsWithoutHilt::class.java) 81 | 82 | val someSteps2 = hiltObjectFactory.getInstance(SomeStepsWithoutHiltAndDependencies::class.java) 83 | 84 | 85 | assertSame(someSteps1.someStepsWithoutHiltAndDependencies,someSteps2) 86 | 87 | hiltObjectFactory.stop() 88 | 89 | } 90 | 91 | @Test 92 | fun `inject cucumber and hilt dependencies using field injection into base class`() { 93 | hiltObjectFactory.start() 94 | 95 | val someSteps1 = hiltObjectFactory.getInstance(StepsWithBaseClass::class.java) 96 | val hook = hiltObjectFactory.getInstance(SomeCucumberHook::class.java) 97 | 98 | 99 | assertNotNull(someSteps1.someDependencies) 100 | assertNotNull(someSteps1.someSingletonDependency) 101 | assertSame(someSteps1.someCucumberHook, hook) 102 | } 103 | 104 | } -------------------------------------------------------------------------------- /cucumber-android-hilt/src/test/java/io/cucumber/android/hilt/SomeCucumberHook.kt: -------------------------------------------------------------------------------- 1 | package io.cucumber.android.hilt 2 | 3 | import javax.inject.Inject 4 | import javax.inject.Singleton 5 | 6 | @Singleton 7 | class SomeCucumberHook @Inject constructor() { 8 | 9 | } -------------------------------------------------------------------------------- /cucumber-android-hilt/src/test/java/io/cucumber/android/hilt/SomeDependencies.kt: -------------------------------------------------------------------------------- 1 | package io.cucumber.android.hilt 2 | 3 | import javax.inject.Inject 4 | import javax.inject.Singleton 5 | 6 | 7 | class SomeDependencies @Inject constructor() { 8 | fun doSomething() { 9 | 10 | } 11 | } 12 | 13 | 14 | @Singleton 15 | class SomeSingletonDependency @Inject constructor() { 16 | fun doSomething() { 17 | 18 | } 19 | } 20 | 21 | class SomeDependency @Inject constructor() { 22 | fun doSomething() { 23 | 24 | } 25 | } -------------------------------------------------------------------------------- /cucumber-android-hilt/src/test/java/io/cucumber/android/hilt/SomeOtherSteps.kt: -------------------------------------------------------------------------------- 1 | package io.cucumber.android.hilt 2 | 3 | import dagger.hilt.android.testing.HiltAndroidTest 4 | import io.cucumber.java.en.Given 5 | import javax.inject.Inject 6 | 7 | @HiltAndroidTest 8 | class SomeOtherSteps { 9 | 10 | @Inject 11 | lateinit var someSingletonDependency: SomeSingletonDependency 12 | 13 | @Inject 14 | lateinit var someDependency: SomeDependency 15 | 16 | 17 | @Given("Something") 18 | fun doSomething() { 19 | someDependency.doSomething() 20 | someSingletonDependency.doSomething() 21 | } 22 | 23 | } -------------------------------------------------------------------------------- /cucumber-android-hilt/src/test/java/io/cucumber/android/hilt/SomeSteps.kt: -------------------------------------------------------------------------------- 1 | package io.cucumber.android.hilt 2 | 3 | import dagger.hilt.android.testing.HiltAndroidTest 4 | import io.cucumber.java.en.Given 5 | import javax.inject.Inject 6 | 7 | @HiltAndroidTest 8 | class SomeSteps { 9 | 10 | @Inject 11 | lateinit var someSingletonDependency: SomeSingletonDependency 12 | 13 | @Inject 14 | lateinit var someDependency: SomeDependency 15 | 16 | 17 | @Given("Something") 18 | fun doSomething() { 19 | someDependency.doSomething() 20 | someSingletonDependency.doSomething() 21 | } 22 | 23 | } -------------------------------------------------------------------------------- /cucumber-android-hilt/src/test/java/io/cucumber/android/hilt/SomeStepsWithoutHilt.kt: -------------------------------------------------------------------------------- 1 | package io.cucumber.android.hilt 2 | 3 | import io.cucumber.java.en.Given 4 | 5 | class SomeStepsWithoutHiltAndDependencies { 6 | 7 | 8 | @Given("Something") 9 | fun doSomething() { 10 | 11 | } 12 | 13 | } 14 | 15 | class SomeStepsWithoutHilt(val someStepsWithoutHiltAndDependencies: SomeStepsWithoutHiltAndDependencies) { 16 | 17 | 18 | @Given("Something") 19 | fun doSomething() { 20 | 21 | } 22 | 23 | } -------------------------------------------------------------------------------- /cucumber-android-hilt/src/test/java/io/cucumber/android/hilt/StepsWithBaseClass.kt: -------------------------------------------------------------------------------- 1 | package io.cucumber.android.hilt 2 | 3 | import dagger.hilt.android.testing.HiltAndroidTest 4 | import javax.inject.Inject 5 | 6 | abstract class BaseSteps { 7 | 8 | @Inject 9 | lateinit var someCucumberHook: SomeCucumberHook 10 | 11 | @Inject 12 | lateinit var someDependencies: SomeDependencies 13 | } 14 | 15 | @HiltAndroidTest 16 | class StepsWithBaseClass:BaseSteps() { 17 | @Inject 18 | lateinit var someSingletonDependency: SomeSingletonDependency 19 | } -------------------------------------------------------------------------------- /cucumber-android-hilt/src/test/resources/robolectric.properties: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cucumber/cucumber-android/0c08b28eefa4eb23fb60fb674b36e956931669bf/cucumber-android-hilt/src/test/resources/robolectric.properties -------------------------------------------------------------------------------- /cucumber-android/.gitignore: -------------------------------------------------------------------------------- 1 | gen/ -------------------------------------------------------------------------------- /cucumber-android/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | apply plugin: 'kotlin-android' 3 | 4 | addAndroidConfig() 5 | addLibraryPublishing('Cucumber-JVM: Android') 6 | 7 | 8 | configurations.all { 9 | // check for updates every build 10 | resolutionStrategy.cacheChangingModulesFor 0, 'seconds' 11 | } 12 | 13 | android { 14 | buildFeatures { 15 | buildConfig = true 16 | } 17 | 18 | defaultConfig { 19 | buildConfigField 'String', 'VERSION', "\"${version}\"" 20 | } 21 | } 22 | 23 | dependencies { 24 | api "io.cucumber:cucumber-java:$cucumber_javaVersion" 25 | api "io.cucumber:cucumber-junit:$cucumber_javaVersion" 26 | api 'junit:junit:4.13.2' 27 | api "androidx.test:runner:1.5.2" 28 | implementation project(':cucumber-junit-rules-support') 29 | 30 | testImplementation "org.robolectric:robolectric:$robolectricVersion" 31 | testImplementation "org.hamcrest:hamcrest-library:2.2" 32 | testImplementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" 33 | } 34 | 35 | -------------------------------------------------------------------------------- /cucumber-android/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /cucumber-android/src/main/java/io/cucumber/android/AndroidBackend.kt: -------------------------------------------------------------------------------- 1 | package io.cucumber.android 2 | 3 | import io.cucumber.core.backend.Backend 4 | import io.cucumber.core.backend.Container 5 | import io.cucumber.core.backend.Glue 6 | import io.cucumber.core.backend.Lookup 7 | import io.cucumber.core.backend.Snippet 8 | import io.cucumber.java.GlueAdaptorWrapper 9 | import io.cucumber.java.MethodScannerWrapper 10 | import java.lang.reflect.Method 11 | import java.net.URI 12 | 13 | internal class AndroidBackend( 14 | private val lookup: Lookup, 15 | private val container: Container, 16 | private val testClassesScanner: TestClassesScanner, 17 | private val rulesBackend: RulesBackend 18 | ) : Backend { 19 | 20 | override fun loadGlue(glue: Glue, gluePaths: List) { 21 | val glueAdaptor = GlueAdaptorWrapper(lookup, glue) 22 | 23 | val packages = gluePaths.map { it.path.removePrefix("/").replace('/', '.') } 24 | 25 | testClassesScanner.getClassesFromRootPackages { fqn -> packages.any { fqn.startsWith(it) } }.forEach { 26 | 27 | MethodScannerWrapper.scan(it) { method: Method, annotation: Annotation -> 28 | container.addClass(method.declaringClass) 29 | glueAdaptor.addDefinition(method, annotation) 30 | } 31 | rulesBackend.scan(it) 32 | } 33 | } 34 | 35 | override fun buildWorld() { 36 | rulesBackend.buildWorld() 37 | } 38 | 39 | override fun disposeWorld() { 40 | rulesBackend.disposeWorld() 41 | } 42 | 43 | override fun getSnippet(): Snippet = KotlinSnippet() 44 | } -------------------------------------------------------------------------------- /cucumber-android/src/main/java/io/cucumber/android/AndroidFeatureSupplier.kt: -------------------------------------------------------------------------------- 1 | package io.cucumber.android 2 | 3 | import android.content.Context 4 | import android.content.res.AssetManager 5 | import io.cucumber.core.feature.FeatureIdentifier 6 | import io.cucumber.core.feature.FeatureParser 7 | import io.cucumber.core.feature.Options 8 | import io.cucumber.core.gherkin.Feature 9 | import io.cucumber.core.resource.Resource 10 | import io.cucumber.core.runtime.FeatureSupplier 11 | import java.io.InputStream 12 | import java.net.URI 13 | import kotlin.jvm.optionals.getOrNull 14 | 15 | internal class AndroidFeatureSupplier( 16 | private val featureOptions: Options, 17 | private val parser: FeatureParser, 18 | private val context: Context 19 | ) : FeatureSupplier { 20 | 21 | companion object { 22 | private const val RESOURCE_PATH_FORMAT = "%s/%s" 23 | } 24 | override fun get(): List { 25 | val resources = ArrayList() 26 | val assetManager = context.assets 27 | 28 | featureOptions.featurePaths.forEach { 29 | addResourceRecursive(resources, assetManager, it) 30 | } 31 | return resources.mapNotNull { parser.parseResource(it).getOrNull() } 32 | } 33 | 34 | private fun addResourceRecursive( 35 | resources: MutableList, 36 | assetManager: AssetManager, 37 | path: URI, 38 | ) { 39 | if (FeatureIdentifier.isFeature(path)) { 40 | resources.add(AndroidResource(context, path)) 41 | return 42 | } 43 | val schemeSpecificPart = path.pathInAssets() 44 | val list = assetManager.list(schemeSpecificPart) 45 | if (list != null) { 46 | for (name in list) { 47 | val subPath: String = String.format(RESOURCE_PATH_FORMAT, path.toString(), name) 48 | addResourceRecursive(resources, assetManager, URI.create(subPath)) 49 | } 50 | } 51 | } 52 | 53 | /** 54 | * Android specific implementation of [Resource] which is apple 55 | * to create [InputStream]s for android assets. 56 | */ 57 | private class AndroidResource( 58 | /** 59 | * The [Context] to get the [InputStream] from 60 | */ 61 | private val context: Context, 62 | /** 63 | * The path of the resource. 64 | */ 65 | val path: URI 66 | ) : Resource { 67 | 68 | private val pathInAssets: String = path.pathInAssets() 69 | 70 | override fun getUri(): URI = path 71 | 72 | override fun getInputStream(): InputStream { 73 | return context.assets.open(pathInAssets, AssetManager.ACCESS_UNKNOWN) 74 | } 75 | 76 | override fun toString(): String { 77 | return "AndroidResource ($pathInAssets)" 78 | } 79 | } 80 | 81 | } 82 | 83 | private fun URI.pathInAssets() = schemeSpecificPart.removePrefix("assets:").removePrefix("/") 84 | -------------------------------------------------------------------------------- /cucumber-android/src/main/java/io/cucumber/android/AndroidLogcatReporter.java: -------------------------------------------------------------------------------- 1 | package io.cucumber.android; 2 | 3 | import android.util.Log; 4 | 5 | import io.cucumber.plugin.ConcurrentEventListener; 6 | import io.cucumber.plugin.event.EventHandler; 7 | import io.cucumber.plugin.event.EventPublisher; 8 | import io.cucumber.plugin.event.PickleStepTestStep; 9 | import io.cucumber.plugin.event.TestCaseStarted; 10 | import io.cucumber.plugin.event.TestRunFinished; 11 | import io.cucumber.plugin.event.TestStepStarted; 12 | 13 | /** 14 | * Logs information about the currently executed statements to androids logcat. 15 | */ 16 | public final class AndroidLogcatReporter implements ConcurrentEventListener { 17 | 18 | /** 19 | * The log tag to be used when logging to logcat. 20 | */ 21 | private final String logTag; 22 | /** 23 | * The event handler that logs the {@link TestCaseStarted} events. 24 | */ 25 | private final EventHandler testCaseStartedHandler = new EventHandler() { 26 | @Override 27 | public void receive(TestCaseStarted event) { 28 | Log.d(logTag, String.format("%s", event.getTestCase().getName())); 29 | } 30 | }; 31 | /** 32 | * The event handler that logs the {@link TestStepStarted} events. 33 | */ 34 | private final EventHandler testStepStartedHandler = new EventHandler() { 35 | @Override 36 | public void receive(TestStepStarted event) { 37 | if (event.getTestStep() instanceof PickleStepTestStep) { 38 | PickleStepTestStep testStep = (PickleStepTestStep) event.getTestStep(); 39 | Log.d(logTag, String.format("%s", testStep.getStep().getText())); 40 | } 41 | } 42 | }; 43 | /** 44 | * The event handler that logs the {@link TestRunFinished} events. 45 | */ 46 | private EventHandler runFinishHandler = new EventHandler() { 47 | @Override 48 | public void receive(TestRunFinished event) { 49 | Throwable error = event.getResult().getError(); 50 | if (error != null) { 51 | Log.e(logTag, error.toString()); 52 | } 53 | } 54 | }; 55 | 56 | /** 57 | * Creates a new instance for the given parameters. 58 | * 59 | * @param logTag the tag to use for logging to logcat 60 | */ 61 | public AndroidLogcatReporter(String logTag) { 62 | this.logTag = logTag; 63 | } 64 | 65 | @Override 66 | public void setEventPublisher(EventPublisher publisher) { 67 | publisher.registerHandlerFor(TestCaseStarted.class, testCaseStartedHandler); 68 | publisher.registerHandlerFor(TestStepStarted.class, testStepStartedHandler); 69 | publisher.registerHandlerFor(TestRunFinished.class, runFinishHandler); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /cucumber-android/src/main/java/io/cucumber/android/CucumberAndroidJUnitArguments.java: -------------------------------------------------------------------------------- 1 | package io.cucumber.android; 2 | 3 | import android.os.Bundle; 4 | 5 | import androidx.annotation.NonNull; 6 | import androidx.annotation.Nullable; 7 | import androidx.test.runner.AndroidJUnitRunner; 8 | 9 | import java.util.HashMap; 10 | import java.util.Map; 11 | 12 | import io.cucumber.core.options.Constants; 13 | 14 | /** 15 | * This class is responsible for preparing bundle to{@link AndroidJUnitRunner} 16 | * for cucumber tests. It prepares bundle for running tests from Android Tests Orchestrator 17 | *

18 | * Runner argument are linked to the {@link androidx.test.internal.runner.RunnerArgs} 19 | */ 20 | public class CucumberAndroidJUnitArguments { 21 | 22 | /** 23 | * Public arguments passed by instrumentation as {@code -e} parameters. 24 | */ 25 | public static class PublicArgs { 26 | 27 | /** 28 | * User default android junit runner. public interface 29 | */ 30 | public static final String USE_DEFAULT_ANDROID_RUNNER = "cucumberUseAndroidJUnitRunner"; 31 | /** 32 | * Custom package location of the {@link io.cucumber.junit.CucumberOptions} annotated class. 33 | */ 34 | public static final String OPTIONS_ANNOTATION_LOCATION = "optionsAnnotationPackage"; 35 | 36 | /** 37 | * Path for features. See Usage {@code cucumber.features} 38 | */ 39 | public static final String FEATURES = "features"; 40 | 41 | /** 42 | * Glue packages. See Usage {@code cucumber.glue} 43 | */ 44 | public static final String GLUE = "glue"; 45 | /** 46 | * Plugins. See Usage {@code cucumber.plugin} 47 | */ 48 | public static final String PLUGIN = "plugin"; 49 | /** 50 | * Tags. See Usage {@code cucumber.filter.tags} 51 | */ 52 | public static final String TAGS = "tags"; 53 | /** 54 | * Regexp for scenarios name. See Usage {@code cucumber.filter.name} 55 | */ 56 | public static final String NAME = "name"; 57 | /** 58 | * Only log scenarios, does not execute steps. See Usage {@code cucumber.execution.dry-run} 59 | */ 60 | public static final String DRY_RUN = "dryRun"; 61 | 62 | /** 63 | * Only log scenarios, does not execute steps. See Usage {@code cucumber.execution.dry-run} 64 | */ 65 | public static final String LOG = "log"; 66 | /** 67 | * Snippet type. See Usage {@code cucumber.snippet-type} 68 | */ 69 | public static final String SNIPPETS = "snippets"; 70 | } 71 | 72 | /** 73 | * Cucumber internal use argument keys. 74 | */ 75 | static class InternalCucumberAndroidArgs { 76 | 77 | static final String CUCUMBER_ANDROID_TEST_CLASS = "cucumberAndroidTestClass"; 78 | } 79 | 80 | /** 81 | * Runner argument are linked to the {@link androidx.test.internal.runner.RunnerArgs}. 82 | */ 83 | static class AndroidJunitRunnerArgs { 84 | 85 | /** 86 | * {@link androidx.test.internal.runner.RunnerArgs#ARGUMENT_RUNNER_BUILDER} 87 | */ 88 | private static final String ARGUMENT_ORCHESTRATOR_RUNNER_BUILDER = "runnerBuilder"; 89 | /** 90 | * {@link androidx.test.internal.runner.RunnerArgs#ARGUMENT_TEST_CLASS} 91 | */ 92 | static final String ARGUMENT_ORCHESTRATOR_CLASS = "class"; 93 | } 94 | 95 | private static final String TRUE = Boolean.TRUE.toString(); 96 | private static final String FALSE = Boolean.FALSE.toString(); 97 | @NonNull 98 | private final Bundle originalArgs; 99 | @Nullable 100 | private Bundle processedArgs; 101 | 102 | public CucumberAndroidJUnitArguments(@NonNull Bundle arguments) { 103 | this.originalArgs = new Bundle(arguments); 104 | } 105 | 106 | @NonNull 107 | public Bundle processArgs() { 108 | processedArgs = new Bundle(originalArgs); 109 | 110 | if (TRUE.equals(originalArgs.getString(PublicArgs.USE_DEFAULT_ANDROID_RUNNER, FALSE))) { 111 | return processedArgs; 112 | } 113 | 114 | processedArgs.putString(AndroidJunitRunnerArgs.ARGUMENT_ORCHESTRATOR_RUNNER_BUILDER, CucumberJUnitRunnerBuilder.class.getName()); 115 | 116 | String testClass = originalArgs.getString(AndroidJunitRunnerArgs.ARGUMENT_ORCHESTRATOR_CLASS); 117 | if (testClass != null && !testClass.isEmpty()) { 118 | 119 | //if this runner is executed for single class (e.g. from orchestrator or spoon), we set 120 | //special option to let CucumberJUnitRunner handle this 121 | processedArgs.putString(InternalCucumberAndroidArgs.CUCUMBER_ANDROID_TEST_CLASS, testClass); 122 | } 123 | 124 | //there is no need to scan all classes especially that class name is a gherkin feature name and loading such class will fail - we can fake this execution to be for single class 125 | //because we delegate test execution to CucumberJUnitRunner 126 | processedArgs.putString(AndroidJunitRunnerArgs.ARGUMENT_ORCHESTRATOR_CLASS, CucumberJUnitRunnerBuilder.class.getName()); 127 | 128 | return processedArgs; 129 | } 130 | 131 | @NonNull 132 | Bundle getRunnerArgs() { 133 | if (processedArgs == null) { 134 | processedArgs = processArgs(); 135 | } 136 | return processedArgs; 137 | } 138 | 139 | /** 140 | * Returns a Cucumber options {@link Map} where keys are supported propertiesProperties, Environment variables, System Options 141 | * 142 | * @return {@link Map} 143 | */ 144 | @NonNull 145 | Map getCucumberOptions() { 146 | 147 | Bundle bundle = getRunnerArgs(); 148 | Map map = new HashMap<>(bundle.size()); 149 | 150 | for (String key : bundle.keySet()) { 151 | if (PublicArgs.GLUE.equals(key)) { 152 | appendOption(map, Constants.GLUE_PROPERTY_NAME, bundle.getString(key)); 153 | } 154 | else if (PublicArgs.PLUGIN.equals(key)) { 155 | appendOption(map, Constants.PLUGIN_PROPERTY_NAME, bundle.getString(key)); 156 | } 157 | else if (PublicArgs.TAGS.equals(key)) { 158 | appendOption(map, Constants.FILTER_TAGS_PROPERTY_NAME, bundle.getString(key)); 159 | } 160 | else if (PublicArgs.NAME.equals(key)) { 161 | appendOption(map, Constants.FILTER_NAME_PROPERTY_NAME, bundle.getString(key)); 162 | } 163 | else if ((PublicArgs.DRY_RUN.equals(key) || PublicArgs.LOG.equals(key)) && getBooleanArgument(bundle, key)) { 164 | appendOption(map, Constants.EXECUTION_DRY_RUN_PROPERTY_NAME, "true"); 165 | } 166 | else if (PublicArgs.SNIPPETS.equals(key)) { 167 | appendOption(map, Constants.SNIPPET_TYPE_PROPERTY_NAME, bundle.getString(key)); 168 | } 169 | else if (PublicArgs.FEATURES.equals(key)) { 170 | appendOption(map, Constants.FEATURES_PROPERTY_NAME, bundle.getString(key)); 171 | } 172 | else if (key.startsWith("cucumber.")) { 173 | appendOption(map, key, bundle.getString(key)); 174 | } 175 | } 176 | return map; 177 | } 178 | 179 | /** 180 | * Adds the given {@code optionKey} and its {@code optionValue} to the given {@code map} 181 | */ 182 | private static void appendOption(Map map, String optionKey, String optionValue) { 183 | map.put(optionKey, optionValue); 184 | } 185 | 186 | /** 187 | * Extracts a boolean value from the bundle which is stored as string. 188 | * Given the string value is "true" the boolean value will be {@code true}, 189 | * given the string value is "false the boolean value will be {@code false}. 190 | * The case in the string is ignored. In case no value is found this method 191 | * returns false. In case the given {@code bundle} is {@code null} {@code false} 192 | * will be returned. 193 | * 194 | * @param bundle the {@link Bundle} to get the value from 195 | * @param key the key to get the value for 196 | * @return the boolean representation of the string value found for the given key, 197 | * or false in case no value was found 198 | */ 199 | private static boolean getBooleanArgument(Bundle bundle, String key) { 200 | 201 | if (bundle == null) { 202 | return false; 203 | } 204 | 205 | String tagString = bundle.getString(key); 206 | return tagString != null && Boolean.parseBoolean(tagString); 207 | } 208 | 209 | @Nullable 210 | String getClassArgument(){ 211 | return getRunnerArgs().getString(InternalCucumberAndroidArgs.CUCUMBER_ANDROID_TEST_CLASS); 212 | } 213 | 214 | @Nullable 215 | String getOptionsAnnotationPackageLocation(){ 216 | return getRunnerArgs().getString(PublicArgs.OPTIONS_ANNOTATION_LOCATION); 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /cucumber-android/src/main/java/io/cucumber/android/CucumberArgumentsProvider.kt: -------------------------------------------------------------------------------- 1 | package io.cucumber.android 2 | 3 | import android.os.Bundle 4 | 5 | internal object CucumberArgumentsProvider { 6 | var arguments = CucumberAndroidJUnitArguments(Bundle.EMPTY) 7 | } -------------------------------------------------------------------------------- /cucumber-android/src/main/java/io/cucumber/android/CucumberJUnitRunnerBuilder.java: -------------------------------------------------------------------------------- 1 | package io.cucumber.android; 2 | 3 | import org.junit.runner.Runner; 4 | import org.junit.runners.model.RunnerBuilder; 5 | 6 | public class CucumberJUnitRunnerBuilder extends RunnerBuilder { 7 | @Override 8 | public Runner runnerForClass(Class testClass) { 9 | if (testClass.equals(getClass())) { 10 | return new CucumberJunitRunner(testClass); 11 | } 12 | 13 | return null; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /cucumber-android/src/main/java/io/cucumber/android/CucumberJunitRunner.kt: -------------------------------------------------------------------------------- 1 | package io.cucumber.android 2 | 3 | import android.content.Context 4 | import android.util.Log 5 | import androidx.test.platform.app.InstrumentationRegistry 6 | import io.cucumber.core.eventbus.EventBus 7 | import io.cucumber.core.exception.CucumberException 8 | import io.cucumber.core.feature.FeatureParser 9 | import io.cucumber.core.filter.Filters 10 | import io.cucumber.core.options.CucumberOptionsAnnotationParser 11 | import io.cucumber.core.options.CucumberProperties 12 | import io.cucumber.core.options.CucumberPropertiesParser 13 | import io.cucumber.core.plugin.PluginFactory 14 | import io.cucumber.core.plugin.Plugins 15 | import io.cucumber.core.resource.ClassLoaders 16 | import io.cucumber.core.runtime.BackendSupplier 17 | import io.cucumber.core.runtime.CucumberAndroidExecutionContext 18 | import io.cucumber.core.runtime.ExitStatus 19 | import io.cucumber.core.runtime.ObjectFactoryServiceLoader 20 | import io.cucumber.core.runtime.SynchronizedEventBus 21 | import io.cucumber.core.runtime.ThreadLocalObjectFactorySupplier 22 | import io.cucumber.core.runtime.ThreadLocalRunnerSupplier 23 | import io.cucumber.core.runtime.TimeServiceEventBus 24 | import io.cucumber.junit.AndroidFeatureRunner 25 | import io.cucumber.junit.CucumberJunitSupport 26 | import io.cucumber.junit.CucumberOptions 27 | import org.junit.runner.Description 28 | import org.junit.runner.notification.RunNotifier 29 | import org.junit.runners.ParentRunner 30 | import org.junit.runners.model.Statement 31 | import java.time.Clock 32 | import java.util.UUID 33 | 34 | internal class CucumberJunitRunner(testClass: Class<*>) : ParentRunner(testClass) { 35 | private var children = emptyList() 36 | private val bus: EventBus 37 | private val plugins: Plugins 38 | private val executionContext: CucumberAndroidExecutionContext 39 | 40 | init { 41 | val instrumentation = InstrumentationRegistry.getInstrumentation() 42 | val context = instrumentation.context 43 | val arguments = CucumberArgumentsProvider.arguments 44 | 45 | val testClassesScanner = TestClassesScanner(instrumentation) 46 | val cucumberOptionsClass = getCucumberOptionsClass(testClassesScanner, arguments, context) 47 | 48 | // Parse the options early to provide fast feedback about invalid 49 | // options 50 | val propertiesFileOptions = CucumberPropertiesParser() 51 | .parse(CucumberProperties.fromPropertiesFile()) 52 | .build() 53 | val annotationOptions = CucumberOptionsAnnotationParser() 54 | .withOptionsProvider(CucumberJunitSupport.jUnitCucumberOptionsProvider()) 55 | .parse(cucumberOptionsClass) 56 | .build(propertiesFileOptions) 57 | val environmentOptions = CucumberPropertiesParser() 58 | .parse(CucumberProperties.fromEnvironment()) 59 | .build(annotationOptions) 60 | val systemOptions = CucumberPropertiesParser() 61 | .parse(CucumberProperties.fromSystemProperties()) 62 | .enablePublishPlugin() 63 | .build(environmentOptions) 64 | 65 | val runtimeOptions = CucumberPropertiesParser() 66 | .parse(arguments.cucumberOptions) 67 | .enablePublishPlugin() 68 | .build(systemOptions) 69 | 70 | bus = SynchronizedEventBus.synchronize(TimeServiceEventBus(Clock.systemUTC()) { UUID.randomUUID() }) 71 | 72 | // Parse the features early. Don't proceed when there are lexer errors 73 | val parser = FeatureParser { bus.generateId() } 74 | val featureSupplier = AndroidFeatureSupplier(runtimeOptions, parser, context) 75 | val features = featureSupplier.get() 76 | 77 | if (features.isEmpty()) { 78 | Log.e(TAG, "No features found") 79 | } 80 | 81 | // Create plugins after feature parsing to avoid the creation of empty 82 | // files on lexer errors. 83 | plugins = Plugins(PluginFactory(), runtimeOptions) 84 | val exitStatus = ExitStatus(runtimeOptions) 85 | plugins.addPlugin(exitStatus) 86 | plugins.addPlugin(AndroidLogcatReporter(TAG)) 87 | val objectFactoryServiceLoader = ObjectFactoryServiceLoader( 88 | ClassLoaders::getDefaultClassLoader, 89 | runtimeOptions 90 | ) 91 | val objectFactorySupplier = ThreadLocalObjectFactorySupplier(objectFactoryServiceLoader) 92 | val rulesBackend = RulesBackend(objectFactorySupplier) 93 | val backendSupplier = BackendSupplier { 94 | val objectFactory = objectFactorySupplier.get() 95 | listOf(AndroidBackend(objectFactory, objectFactory, testClassesScanner, rulesBackend)) 96 | } 97 | val runnerSupplier = ThreadLocalRunnerSupplier( 98 | runtimeOptions, bus, backendSupplier, 99 | objectFactorySupplier 100 | ) 101 | executionContext = CucumberAndroidExecutionContext(bus, exitStatus, runnerSupplier) 102 | val filters = Filters(runtimeOptions) 103 | val testClassNameFromRunner = arguments.classArgument 104 | var featureFilter = { _: String -> true } 105 | var scenarioFilter = { _: String, _:String -> true } 106 | if (testClassNameFromRunner != null) { 107 | Log.i(TAG, "${CucumberAndroidJUnitArguments.AndroidJunitRunnerArgs.ARGUMENT_ORCHESTRATOR_CLASS}=$testClassNameFromRunner") 108 | 109 | val allFeaturesAndScenarios = getFeaturesAndScenariosNamesFromClassArgument(testClassNameFromRunner) 110 | 111 | if (allFeaturesAndScenarios.isNotEmpty()) { 112 | featureFilter = { allFeaturesAndScenarios.containsKey(it) } 113 | scenarioFilter = { feature,scenario -> allFeaturesAndScenarios[feature]?.let { 114 | it.isEmpty() || it.contains(scenario) 115 | }?:false } 116 | } else { 117 | Log.e(TAG, "CucumberJUnitRunner: invalid argument ${CucumberAndroidJUnitArguments.AndroidJunitRunnerArgs.ARGUMENT_ORCHESTRATOR_CLASS}=$testClassNameFromRunner") 118 | } 119 | } 120 | children = CucumberJunitSupport.createChildren( 121 | features = features, 122 | featureFilter = featureFilter, 123 | cucumberOptionsClass = cucumberOptionsClass, 124 | rulesBackend = rulesBackend, 125 | scenarioFilter = scenarioFilter, 126 | pickleFilter = filters, 127 | executionContext = executionContext 128 | ) 129 | } 130 | 131 | private fun getFeaturesAndScenariosNamesFromClassArgument(testClassNameFromRunner: String): Map> { 132 | //it can contain many classes and methods names separated by comma 133 | //as described in androidx.test.internal.runner.ClassPathScanner 134 | val allClassesAndMethods = testClassNameFromRunner.split(',').filter { it.isNotEmpty() } 135 | 136 | return allClassesAndMethods.mapNotNull { 137 | it.split('#').takeIf { it.size in 1..2 }?.let { featureAndScenario -> 138 | featureAndScenario[0] to featureAndScenario.getOrNull(1) 139 | } 140 | }.groupBy(keySelector = {it.first}, valueTransform = {it.second}).mapValues { it.value.filterNotNull() } 141 | } 142 | 143 | private fun getCucumberOptionsClass(testClassesScanner: TestClassesScanner, arguments: CucumberAndroidJUnitArguments, context: Context): Class<*> { 144 | val packageName = arguments.optionsAnnotationPackageLocation ?: context.packageName 145 | val classes = testClassesScanner.getClassesFromRootPackages { 146 | val lastIndexOfPackage = it.lastIndexOf('.') 147 | val classPackage = if (lastIndexOfPackage == -1) "" else it.substring(0, lastIndexOfPackage) 148 | classPackage == packageName 149 | }.filter { it.isAnnotationPresent(CucumberOptions::class.java) } 150 | Log.i(TAG, "Found ${classes.size} ${if (classes.size==1) "class" else "classes"} with CucumberOptions annotation in package $packageName: ${classes.map { it.name }}") 151 | 152 | return classes.firstOrNull() ?: throw CucumberException("No CucumberOptions annotated class present in package $packageName") 153 | } 154 | 155 | public override fun getChildren(): List { 156 | return children 157 | } 158 | 159 | override fun describeChild(child: AndroidFeatureRunner): Description { 160 | return child.description 161 | } 162 | 163 | override fun runChild(child: AndroidFeatureRunner, notifier: RunNotifier) { 164 | child.run(notifier) 165 | } 166 | 167 | override fun childrenInvoker(notifier: RunNotifier): Statement { 168 | var statement = super.childrenInvoker(notifier) 169 | statement = StartAndFinishTestRun(statement) 170 | return statement 171 | } 172 | 173 | private inner class StartAndFinishTestRun(private val next: Statement) : Statement() { 174 | override fun evaluate() { 175 | plugins.setEventBusOnEventListenerPlugins(bus) 176 | executionContext.runFeatures { next.evaluate() } 177 | } 178 | } 179 | 180 | companion object { 181 | /** 182 | * The logcat tag to log all cucumber related information to. 183 | */ 184 | const val TAG = "cucumber-android" 185 | } 186 | } -------------------------------------------------------------------------------- /cucumber-android/src/main/java/io/cucumber/android/KotlinSnippet.kt: -------------------------------------------------------------------------------- 1 | package io.cucumber.android 2 | 3 | import io.cucumber.core.backend.Snippet 4 | import java.lang.reflect.Type 5 | import java.text.MessageFormat 6 | 7 | internal class KotlinSnippet : Snippet { 8 | override fun tableHint(): String = "" 9 | override fun arguments(arguments: MutableMap): String { 10 | return arguments.entries.joinToString { "${(it.value as? Class<*>)?.simpleName ?: it.value.toString()} ${it.key}" } 11 | } 12 | 13 | override fun escapePattern(pattern: String): String { 14 | return pattern.replace("\\", "\\\\").replace("\"", "\\\"") 15 | } 16 | 17 | override fun template(): MessageFormat { 18 | return MessageFormat("@{0}(\"{1}\")\n fun {2}({3} {5}) \'{\'\n // {4}\n throw PendingException()\n\'}\'\n") 19 | } 20 | 21 | } -------------------------------------------------------------------------------- /cucumber-android/src/main/java/io/cucumber/android/RulesBackend.kt: -------------------------------------------------------------------------------- 1 | package io.cucumber.android 2 | 3 | import io.cucumber.core.runtime.ObjectFactorySupplier 4 | import io.cucumber.junit.TestRuleAccessor 5 | import io.cucumber.junit.TestRulesData 6 | import io.cucumber.junit.TestRulesExecutor 7 | import io.cucumber.junit.WithJunitRule 8 | import org.junit.Rule 9 | import org.junit.rules.TestRule 10 | import org.junit.runner.Description 11 | import java.lang.reflect.Field 12 | import java.lang.reflect.Method 13 | import java.util.concurrent.Executors 14 | 15 | internal class RulesBackend( 16 | private val objectFactorySupplier: ObjectFactorySupplier 17 | ) { 18 | private val classesWithRules: MutableList = ArrayList() 19 | private var rulesExecutor: TestRulesExecutor? = null 20 | private val executorService = Executors.newSingleThreadExecutor() 21 | private var description: Description? = null 22 | private var tags:List = emptyList() 23 | fun buildWorld() { 24 | val objects = ArrayList(classesWithRules.size) 25 | val objectFactory = objectFactorySupplier.get() 26 | for (clazzRules in classesWithRules) { 27 | val instance = objectFactory.getInstance(clazzRules.declaringClass) 28 | objects.add(TestRulesData(clazzRules.useAsTestClassInDescription(), instance, clazzRules.accessors, clazzRules.tagExpression)) 29 | } 30 | rulesExecutor = TestRulesExecutor(objects, executorService) 31 | rulesExecutor?.startRules(description, tags) 32 | } 33 | 34 | fun setDescription(description: Description?, tags:List) { 35 | this.description = description 36 | this.tags = tags 37 | } 38 | 39 | fun disposeWorld() { 40 | rulesExecutor?.stopRules() 41 | description = null 42 | tags = emptyList() 43 | } 44 | 45 | fun scan(glueClass: Class<*>) { 46 | val objectFactory = objectFactorySupplier.get() 47 | val annotation = glueClass.getAnnotation(WithJunitRule::class.java) 48 | if (annotation != null) { 49 | if (objectFactory.addClass(glueClass)) { 50 | classesWithRules.add(TestRulesData(annotation.useAsTestClassInDescription, glueClass, getAccessors(glueClass), annotation.value)) 51 | } 52 | } 53 | } 54 | 55 | private class FieldRuleAccessor(val field: Field, private val order: Int) : TestRuleAccessor { 56 | override fun getRule(obj: Any): TestRule { 57 | return field[obj] as TestRule 58 | } 59 | 60 | override fun getOrder(): Int { 61 | return order 62 | } 63 | } 64 | 65 | private class MethodRuleAccessor(val method: Method, private val order: Int) : TestRuleAccessor { 66 | override fun getRule(obj: Any): TestRule { 67 | return method.invoke(obj) as TestRule 68 | } 69 | 70 | override fun getOrder(): Int { 71 | return order 72 | } 73 | } 74 | 75 | private fun getAccessors(clazz: Class<*>): List { 76 | val accessors: MutableList = ArrayList(1) 77 | for (m in clazz.methods) { 78 | val annotation = m.getAnnotation(Rule::class.java) 79 | if (annotation != null) { 80 | accessors.add(MethodRuleAccessor(m, annotation.order)) 81 | } 82 | } 83 | for (f in clazz.fields) { 84 | val annotation = f.getAnnotation(Rule::class.java) 85 | if (annotation != null) { 86 | accessors.add(FieldRuleAccessor(f, annotation.order)) 87 | } 88 | } 89 | return accessors 90 | } 91 | } -------------------------------------------------------------------------------- /cucumber-android/src/main/java/io/cucumber/android/TestClassesScanner.kt: -------------------------------------------------------------------------------- 1 | package io.cucumber.android 2 | 3 | import android.annotation.SuppressLint 4 | import android.app.Instrumentation 5 | import androidx.test.internal.runner.ClassPathScanner 6 | 7 | @SuppressLint("RestrictedApi") 8 | internal class TestClassesScanner(instrumentation: Instrumentation) { 9 | 10 | private val classPathEntries by lazy { 11 | ClassPathScanner(ClassPathScanner.getDefaultClasspaths(instrumentation)).classPathEntries 12 | } 13 | 14 | fun getClassesFromRootPackages(filter: (fqn:String) -> Boolean): List> { 15 | return classPathEntries.mapNotNull { className -> if (filter(className)) Class.forName(className) else null } 16 | } 17 | 18 | } -------------------------------------------------------------------------------- /cucumber-android/src/main/java/io/cucumber/android/runner/CucumberAndroidJUnitRunner.java: -------------------------------------------------------------------------------- 1 | package io.cucumber.android.runner; 2 | 3 | import android.os.Bundle; 4 | 5 | import androidx.test.runner.AndroidJUnitRunner; 6 | 7 | import io.cucumber.android.CucumberAndroidJUnitArguments; 8 | import io.cucumber.android.CucumberArgumentsProvider; 9 | 10 | /** 11 | * {@link AndroidJUnitRunner} for cucumber tests. It supports running tests from Android Tests Orchestrator 12 | */ 13 | public class CucumberAndroidJUnitRunner extends AndroidJUnitRunner { 14 | 15 | @Override 16 | public void onCreate(Bundle bundle) { 17 | CucumberAndroidJUnitArguments cucumberAndroidJUnitArguments = new CucumberAndroidJUnitArguments(bundle); 18 | CucumberArgumentsProvider.INSTANCE.setArguments(cucumberAndroidJUnitArguments); 19 | super.onCreate(cucumberAndroidJUnitArguments.processArgs()); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /cucumber-android/src/main/java/io/cucumber/core/runtime/CucumberAndroidExecutionContext.kt: -------------------------------------------------------------------------------- 1 | package io.cucumber.core.runtime 2 | 3 | import android.annotation.SuppressLint 4 | import android.os.Build 5 | import io.cucumber.android.cucumber_android.BuildConfig 6 | import io.cucumber.core.eventbus.EventBus 7 | import io.cucumber.core.exception.ExceptionUtils 8 | import io.cucumber.core.exception.UnrecoverableExceptions 9 | import io.cucumber.core.gherkin.Feature 10 | import io.cucumber.core.logging.LoggerFactory 11 | import io.cucumber.core.runner.Runner 12 | import io.cucumber.messages.Convertor 13 | import io.cucumber.messages.ProtocolVersion 14 | import io.cucumber.messages.types.Ci 15 | import io.cucumber.messages.types.Envelope 16 | import io.cucumber.messages.types.Git 17 | import io.cucumber.messages.types.Meta 18 | import io.cucumber.messages.types.Product 19 | import io.cucumber.plugin.event.Node 20 | import io.cucumber.plugin.event.Result 21 | import io.cucumber.plugin.event.Status 22 | import io.cucumber.plugin.event.TestRunFinished 23 | import io.cucumber.plugin.event.TestRunStarted 24 | import io.cucumber.plugin.event.TestSourceParsed 25 | import io.cucumber.plugin.event.TestSourceRead 26 | import java.time.Duration 27 | import java.time.Instant 28 | 29 | /** 30 | * This class is copied from [CucumberExecutionContext] to workaround issue with exception in [Ci] resolving 31 | * 32 | * ``` 33 | * Caused by: java.util.regex.PatternSyntaxException: Syntax error in regexp pattern near index 32 34 | * \$\{(.*?)(?:(?= Build.VERSION_CODES.M) Build.VERSION.BASE_OS else "Android", Build.VERSION.RELEASE), 67 | Product(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) Build.SUPPORTED_ABIS[0] else Build.CPU_ABI, null), 68 | Ci("unknown", "unknown", "unknown", Git("unknown", "unknown", "unknown", "unknown")) 69 | ) 70 | } 71 | 72 | private fun emitTestRunStarted() { 73 | log.debug { "Sending run test started event" } 74 | start = bus.instant 75 | bus.send(TestRunStarted(start)) 76 | bus.send(Envelope.of(io.cucumber.messages.types.TestRunStarted(Convertor.toMessage(start)))) 77 | } 78 | 79 | fun runBeforeAllHooks() { 80 | val runner = runner 81 | collector.executeAndThrow { runner.runBeforeAllHooks() } 82 | } 83 | 84 | fun runAfterAllHooks() { 85 | val runner = runner 86 | collector.executeAndThrow { runner.runAfterAllHooks() } 87 | } 88 | 89 | fun finishTestRun() { 90 | log.debug { "Sending test run finished event" } 91 | val cucumberException = throwable 92 | emitTestRunFinished(cucumberException) 93 | } 94 | 95 | val throwable: Throwable? 96 | get() = collector.throwable 97 | 98 | private fun emitTestRunFinished(exception: Throwable?) { 99 | val instant = bus.instant 100 | val result = Result( 101 | if (exception != null) Status.FAILED else exitStatus.status, 102 | Duration.between(start, instant), 103 | exception 104 | ) 105 | bus.send(TestRunFinished(instant, result)) 106 | val testRunFinished = io.cucumber.messages.types.TestRunFinished( 107 | if (exception != null) ExceptionUtils.printStackTrace(exception) else null, 108 | exception == null && exitStatus.isSuccess, 109 | Convertor.toMessage(instant), 110 | if (exception == null) null else Convertor.toMessage(exception) 111 | ) 112 | bus.send(Envelope.of(testRunFinished)) 113 | } 114 | 115 | fun beforeFeature(feature: Feature) { 116 | log.debug { "Sending test source read event for " + feature.uri } 117 | bus.send(TestSourceRead(bus.instant, feature.uri, feature.source)) 118 | bus.send(TestSourceParsed(bus.instant, feature.uri, listOf(feature))) 119 | bus.sendAll(feature.parseEvents) 120 | } 121 | 122 | fun runTestCase(execution: (Runner) -> Unit) { 123 | val runner = runner 124 | collector.executeAndThrow { execution(runner) } 125 | } 126 | 127 | private val runner: Runner 128 | get() = collector.executeAndThrow { runnerSupplier.get() } 129 | 130 | fun runFeatures(executeFeatures: ThrowingRunnable) { 131 | startTestRun() 132 | execute { 133 | runBeforeAllHooks() 134 | executeFeatures.run() 135 | } 136 | try { 137 | execute { runAfterAllHooks() } 138 | } finally { 139 | finishTestRun() 140 | } 141 | val throwable = throwable 142 | if (throwable != null) { 143 | ExceptionUtils.throwAsUncheckedException(throwable) 144 | } 145 | } 146 | 147 | private fun execute(runnable: ThrowingRunnable) { 148 | try { 149 | runnable.run() 150 | } catch (t: Throwable) { 151 | // Collected in CucumberExecutionContext 152 | UnrecoverableExceptions.rethrowIfUnrecoverable(t) 153 | } 154 | } 155 | 156 | companion object { 157 | private val log = LoggerFactory.getLogger(CucumberAndroidExecutionContext::class.java) 158 | } 159 | } -------------------------------------------------------------------------------- /cucumber-android/src/main/java/io/cucumber/cucumberexpressions/AndroidPatternCompiler.java: -------------------------------------------------------------------------------- 1 | package io.cucumber.cucumberexpressions; 2 | 3 | import android.os.Build; 4 | 5 | import java.util.regex.Pattern; 6 | 7 | public class AndroidPatternCompiler implements PatternCompiler { 8 | 9 | private static final int UNICODE_CHARACTER_CLASS = 0x100; 10 | 11 | @Override 12 | public Pattern compile(String regexp, int flags) { 13 | return Pattern.compile(regexp, flags & ~(Build.VERSION.SDK_INT >= Build.VERSION_CODES.N ? Pattern.UNICODE_CHARACTER_CLASS : UNICODE_CHARACTER_CLASS)); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /cucumber-android/src/main/java/io/cucumber/java/GlueAdaptorWrapper.kt: -------------------------------------------------------------------------------- 1 | package io.cucumber.java 2 | 3 | import io.cucumber.core.backend.Glue 4 | import io.cucumber.core.backend.Lookup 5 | import java.lang.reflect.Method 6 | 7 | /** 8 | * Allows internal access to [GlueAdaptor] 9 | */ 10 | internal class GlueAdaptorWrapper(lookup:Lookup, glue: Glue) { 11 | 12 | private val glueAdaptor = GlueAdaptor(lookup, glue) 13 | 14 | fun addDefinition(method: Method, annotation: Annotation){ 15 | glueAdaptor.addDefinition(method, annotation) 16 | } 17 | } -------------------------------------------------------------------------------- /cucumber-android/src/main/java/io/cucumber/java/MethodScannerWrapper.kt: -------------------------------------------------------------------------------- 1 | package io.cucumber.java 2 | 3 | import java.lang.reflect.Method 4 | import java.util.function.BiConsumer 5 | 6 | /** 7 | * Allows internal access to [MethodScanner] 8 | */ 9 | internal object MethodScannerWrapper { 10 | fun scan(aClass: Class<*>, consumer: BiConsumer){ 11 | MethodScanner.scan(aClass, consumer) 12 | } 13 | } -------------------------------------------------------------------------------- /cucumber-android/src/main/java/io/cucumber/junit/AndroidFeatureRunner.kt: -------------------------------------------------------------------------------- 1 | package io.cucumber.junit 2 | 3 | import android.annotation.SuppressLint 4 | import io.cucumber.android.RulesBackend 5 | import io.cucumber.core.exception.UnrecoverableExceptions 6 | import io.cucumber.core.gherkin.Feature 7 | import io.cucumber.core.gherkin.Pickle 8 | import io.cucumber.core.runtime.CucumberAndroidExecutionContext 9 | import io.cucumber.junit.PickleRunners.PickleRunner 10 | import org.junit.runner.Description 11 | import org.junit.runner.notification.Failure 12 | import org.junit.runner.notification.RunNotifier 13 | import org.junit.runners.ParentRunner 14 | import java.util.function.Predicate 15 | 16 | @SuppressLint("NewApi") 17 | internal class AndroidFeatureRunner( 18 | private val feature: Feature, 19 | private val name: String, 20 | pickleRunnerFilter: (String) -> Boolean, 21 | pickleFilter: Predicate, 22 | private val executionContext: CucumberAndroidExecutionContext, 23 | private val options: JUnitOptions, 24 | private val rulesBackend: RulesBackend, 25 | ) : ParentRunner(null as Class<*>?) { 26 | private val children: List 27 | init { 28 | val groupedByName = feature.pickles.groupBy { it.name } 29 | val featureName = name 30 | children = feature.pickles.mapNotNull { pickle-> 31 | if (!pickleFilter.test(pickle)) return@mapNotNull null 32 | 33 | val scenarioName = CucumberJunitSupport.createName(pickle, options,groupedByName, { it.name }, { it }) 34 | 35 | if (!pickleRunnerFilter(scenarioName)) return@mapNotNull null 36 | 37 | AndroidPickleRunner(pickle, scenarioName, featureName, rulesBackend, executionContext, options) 38 | } 39 | 40 | } 41 | 42 | val isEmpty: Boolean 43 | get() = children.isEmpty() 44 | 45 | public override fun getName(): String { 46 | return this.name 47 | } 48 | 49 | override fun getChildren(): List = children 50 | 51 | override fun describeChild(child: PickleRunner): Description = child.description 52 | 53 | override fun run(notifier: RunNotifier) { 54 | executionContext.beforeFeature(feature) 55 | super.run(notifier) 56 | } 57 | 58 | override fun runChild(child: PickleRunner, notifier: RunNotifier) { 59 | notifier.fireTestStarted(describeChild(child)) 60 | try { 61 | child.run(notifier) 62 | } catch (t: Throwable) { 63 | UnrecoverableExceptions.rethrowIfUnrecoverable(t) 64 | notifier.fireTestFailure(Failure(describeChild(child), t)) 65 | notifier.pleaseStop() 66 | } finally { 67 | notifier.fireTestFinished(describeChild(child)) 68 | } 69 | } 70 | } -------------------------------------------------------------------------------- /cucumber-android/src/main/java/io/cucumber/junit/AndroidPickleRunner.kt: -------------------------------------------------------------------------------- 1 | package io.cucumber.junit 2 | 3 | import io.cucumber.android.RulesBackend 4 | import io.cucumber.core.gherkin.Pickle 5 | import io.cucumber.core.runtime.CucumberAndroidExecutionContext 6 | import io.cucumber.junit.PickleRunners.PickleId 7 | import io.cucumber.junit.PickleRunners.PickleRunner 8 | import io.cucumber.plugin.event.Step 9 | import org.junit.runner.Description 10 | import org.junit.runner.notification.RunNotifier 11 | 12 | internal class AndroidPickleRunner( 13 | private val pickle: Pickle, 14 | private val name: String, 15 | private val featureName: String, 16 | private val rulesBackend: RulesBackend, 17 | private val executionContext: CucumberAndroidExecutionContext, 18 | private val jUnitOptions: JUnitOptions, 19 | ) : PickleRunner { 20 | 21 | private val _description by lazy { 22 | Description.createTestDescription(featureName, name, PickleId(pickle)) 23 | } 24 | 25 | override fun run(notifier: RunNotifier) { 26 | executionContext.runTestCase{ runner -> 27 | val jUnitReporter = JUnitReporter(runner.bus, jUnitOptions) 28 | jUnitReporter.startExecutionUnit(this, notifier) 29 | rulesBackend.setDescription(_description, pickle.tags) 30 | runner.runPickle(pickle) 31 | jUnitReporter.finishExecutionUnit() 32 | } 33 | } 34 | 35 | override fun getDescription(): Description = _description 36 | 37 | override fun describeChild(step: Step?): Description { 38 | throw UnsupportedOperationException("This pickle runner does not wish to describe its children") 39 | } 40 | } -------------------------------------------------------------------------------- /cucumber-android/src/main/java/io/cucumber/junit/CucumberJunitSupport.kt: -------------------------------------------------------------------------------- 1 | package io.cucumber.junit 2 | 3 | import io.cucumber.android.RulesBackend 4 | import io.cucumber.core.gherkin.Feature 5 | import io.cucumber.core.gherkin.Pickle 6 | import io.cucumber.core.options.CucumberOptionsAnnotationParser 7 | import io.cucumber.core.options.CucumberProperties 8 | import io.cucumber.core.runtime.CucumberAndroidExecutionContext 9 | import java.util.function.Predicate 10 | 11 | internal object CucumberJunitSupport { 12 | 13 | fun jUnitCucumberOptionsProvider(): CucumberOptionsAnnotationParser.OptionsProvider = JUnitCucumberOptionsProvider() 14 | 15 | private fun createJunitOptions(cucumberOptionsClass: Class<*>): JUnitOptions { 16 | 17 | // Next parse the junit options 18 | val junitPropertiesFileOptions = JUnitOptionsParser() 19 | .parse(CucumberProperties.fromPropertiesFile()) 20 | .build() 21 | 22 | val junitAnnotationOptions: JUnitOptions = JUnitOptionsParser() 23 | .parse(cucumberOptionsClass) 24 | .build(junitPropertiesFileOptions) 25 | 26 | val junitEnvironmentOptions = JUnitOptionsParser() 27 | .parse(CucumberProperties.fromEnvironment()) 28 | .build(junitAnnotationOptions) 29 | 30 | return JUnitOptionsParser() 31 | .parse(CucumberProperties.fromSystemProperties()) 32 | .build(junitEnvironmentOptions) 33 | } 34 | 35 | fun createChildren( 36 | features: List, 37 | featureFilter: (String) -> Boolean, 38 | cucumberOptionsClass: Class<*>, 39 | rulesBackend: RulesBackend, 40 | scenarioFilter: (feature:String,scenario: String) -> Boolean, 41 | pickleFilter: Predicate, 42 | executionContext: CucumberAndroidExecutionContext, 43 | ): List { 44 | val groupedByName = features.groupBy { it.name } 45 | val junitOptions = createJunitOptions(cucumberOptionsClass) 46 | return features.mapNotNull { feature -> 47 | val featureName = createName(feature, junitOptions, groupedByName, { it.name }, { it.orElse("EMPTY_NAME") }) 48 | 49 | if (!featureFilter(featureName)) return@mapNotNull null 50 | 51 | AndroidFeatureRunner(feature, featureName, {scenarioFilter(featureName,it)}, pickleFilter, executionContext, junitOptions, rulesBackend).takeIf { !it.isEmpty} 52 | } 53 | } 54 | 55 | 56 | fun createName(obj: V, options: JUnitOptions, groupedByName: Map>, key: (V) -> K, name: (K) -> String): String { 57 | val uniqueSuffix = FileNameCompatibleNames.uniqueSuffix(groupedByName, obj) { key(it) } 58 | ?.let { " $it" }.orEmpty() 59 | val originalName = obj.let(key).let(name) 60 | return FileNameCompatibleNames.createName("${originalName}${uniqueSuffix}", options.filenameCompatibleNames()) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /cucumber-android/src/main/java/io/cucumber/junit/WithJunitRule.java: -------------------------------------------------------------------------------- 1 | package io.cucumber.junit; 2 | 3 | import java.lang.annotation.Retention; 4 | import java.lang.annotation.RetentionPolicy; 5 | 6 | /** 7 | * Use it to annotate class which contains Junit {@link org.junit.Rule} 8 | */ 9 | @Retention(RetentionPolicy.RUNTIME) 10 | public @interface WithJunitRule { 11 | 12 | /** 13 | * By default Cucumber treats feature as test class which is not real class 14 | * and cannot be treated as such in runtime. 15 | * E.g HiltAndroidRule relies on having test class passed to its constructor matching 16 | * that passed to {@link org.junit.runner.Description} 17 | * @return false by default, true if you want this class to be passed as test 18 | * class for its junit rules 19 | */ 20 | boolean useAsTestClassInDescription() default false; 21 | 22 | /** 23 | * Tag expression. If the expression applies to the current scenario JUnit rule declared in class annotated by {@link WithJunitRule} will be used 24 | */ 25 | String value() default ""; 26 | } 27 | -------------------------------------------------------------------------------- /cucumber-android/src/main/java/javax/lang/model/SourceVersion.kt: -------------------------------------------------------------------------------- 1 | package javax.lang.model 2 | 3 | /** 4 | * This class is a stub for the real class in the JDK which does not exist in Android. 5 | */ 6 | internal object SourceVersion { 7 | @JvmStatic 8 | fun isName(@Suppress("UNUSED_PARAMETER") name: CharSequence?): Boolean { 9 | return true 10 | } 11 | } -------------------------------------------------------------------------------- /cucumber-android/src/main/resources/META-INF/services/io.cucumber.cucumberexpressions.PatternCompiler: -------------------------------------------------------------------------------- 1 | io.cucumber.cucumberexpressions.AndroidPatternCompiler -------------------------------------------------------------------------------- /cucumber-android/src/test/assets/features/feature1.feature: -------------------------------------------------------------------------------- 1 | Feature: Feature 1 2 | 3 | Scenario Outline: Scenario Outline 1 4 | Given Step 1 5 | When Step 2 6 | Then Step 3 7 | 8 | Examples: 9 | | Column 1 | Column 2 | 10 | | Value 1 | Value 2 | 11 | | Value 3 | Value 4 | -------------------------------------------------------------------------------- /cucumber-android/src/test/assets/features/feature2.feature: -------------------------------------------------------------------------------- 1 | Feature: Feature 2 2 | 3 | Scenario Outline: Scenario Outline 1 4 | Given Step 1 5 | When Step 2 6 | Then Step 3 7 | 8 | Examples: 9 | | Column 1 | Column 2 | 10 | | Value 1 | Value 2 | 11 | | Value 3 | Value 4 | 12 | | Value 4 | Value 5 | -------------------------------------------------------------------------------- /cucumber-android/src/test/java/io/cucumber/android/CucumberAndroidJUnitArgumentsTest.java: -------------------------------------------------------------------------------- 1 | package io.cucumber.android; 2 | 3 | import static org.hamcrest.CoreMatchers.is; 4 | import static org.hamcrest.MatcherAssert.assertThat; 5 | 6 | import android.os.Bundle; 7 | 8 | import androidx.annotation.NonNull; 9 | 10 | import org.hamcrest.Matcher; 11 | import org.hamcrest.Matchers; 12 | import org.junit.Test; 13 | import org.junit.runner.RunWith; 14 | import org.robolectric.RobolectricTestRunner; 15 | import org.robolectric.annotation.Config; 16 | 17 | import java.util.Collections; 18 | import java.util.HashMap; 19 | import java.util.Map; 20 | 21 | import io.cucumber.core.options.Constants; 22 | 23 | @Config(manifest = Config.NONE) 24 | @RunWith(RobolectricTestRunner.class) 25 | public class CucumberAndroidJUnitArgumentsTest { 26 | 27 | 28 | @Test 29 | public void handles_empty_bundle_gracefully() { 30 | 31 | // given 32 | CucumberAndroidJUnitArguments arguments = new CucumberAndroidJUnitArguments(new Bundle()); 33 | 34 | // when 35 | Map cucumberOptions = arguments.getCucumberOptions(); 36 | 37 | // then 38 | assertThat(cucumberOptions, Matchers.is(Collections.emptyMap())); 39 | } 40 | 41 | @Test 42 | public void supports_glue_as_direct_bundle_argument() { 43 | 44 | // given 45 | Bundle bundle = new Bundle(); 46 | bundle.putString("glue", "glue/code/path"); 47 | CucumberAndroidJUnitArguments arguments = new CucumberAndroidJUnitArguments(bundle); 48 | 49 | // when 50 | Map cucumberOptions = arguments.getCucumberOptions(); 51 | 52 | // then 53 | assertThat(cucumberOptions, getMatcher(Constants.GLUE_PROPERTY_NAME,"glue/code/path")); 54 | } 55 | 56 | 57 | @Test 58 | public void supports_plugin_as_direct_bundle_argument() { 59 | 60 | // given 61 | Bundle bundle = new Bundle(); 62 | bundle.putString("plugin", "someFormat"); 63 | CucumberAndroidJUnitArguments arguments = new CucumberAndroidJUnitArguments(bundle); 64 | 65 | // when 66 | Map cucumberOptions = arguments.getCucumberOptions(); 67 | 68 | // then 69 | assertThat(cucumberOptions, getMatcher(Constants.PLUGIN_PROPERTY_NAME,"someFormat")); 70 | } 71 | 72 | @Test 73 | public void supports_tags_as_direct_bundle_argument() { 74 | 75 | // given 76 | Bundle bundle = new Bundle(); 77 | bundle.putString("tags", "@someTag"); 78 | CucumberAndroidJUnitArguments arguments = new CucumberAndroidJUnitArguments(bundle); 79 | 80 | // when 81 | Map cucumberOptions = arguments.getCucumberOptions(); 82 | 83 | // then 84 | assertThat(cucumberOptions, getMatcher(Constants.FILTER_TAGS_PROPERTY_NAME,"@someTag")); 85 | } 86 | 87 | @Test 88 | public void supports_name_as_direct_bundle_argument() { 89 | 90 | // given 91 | Bundle bundle = new Bundle(); 92 | bundle.putString("name", "someName"); 93 | CucumberAndroidJUnitArguments arguments = new CucumberAndroidJUnitArguments(bundle); 94 | 95 | // when 96 | Map cucumberOptions = arguments.getCucumberOptions(); 97 | 98 | // then 99 | assertThat(cucumberOptions, getMatcher(Constants.FILTER_NAME_PROPERTY_NAME,"someName")); 100 | } 101 | 102 | @Test 103 | public void supports_dryRun_as_direct_bundle_argument() { 104 | 105 | // given 106 | Bundle bundle = new Bundle(); 107 | bundle.putString("dryRun", "true"); 108 | CucumberAndroidJUnitArguments arguments = new CucumberAndroidJUnitArguments(bundle); 109 | 110 | // when 111 | Map cucumberOptions = arguments.getCucumberOptions(); 112 | 113 | // then 114 | assertThat(cucumberOptions, getMatcher(Constants.EXECUTION_DRY_RUN_PROPERTY_NAME,"true")); 115 | } 116 | 117 | @Test 118 | public void supports_log_as_alias_for_dryRun_as_direct_bundle_argument() { 119 | 120 | // given 121 | Bundle bundle = new Bundle(); 122 | bundle.putString("log", "true"); 123 | CucumberAndroidJUnitArguments arguments = new CucumberAndroidJUnitArguments(bundle); 124 | 125 | // when 126 | Map cucumberOptions = arguments.getCucumberOptions(); 127 | 128 | // then 129 | assertThat(cucumberOptions, getMatcher(Constants.EXECUTION_DRY_RUN_PROPERTY_NAME,"true")); 130 | } 131 | 132 | 133 | @Test 134 | public void supports_snippets_as_direct_bundle_argument() { 135 | 136 | // given 137 | Bundle bundle = new Bundle(); 138 | bundle.putString("snippets", "someSnippet"); 139 | CucumberAndroidJUnitArguments arguments = new CucumberAndroidJUnitArguments(bundle); 140 | 141 | // when 142 | Map cucumberOptions = arguments.getCucumberOptions(); 143 | 144 | // then 145 | assertThat(cucumberOptions, getMatcher(Constants.SNIPPET_TYPE_PROPERTY_NAME,"someSnippet")); 146 | } 147 | 148 | @NonNull 149 | private static Matcher> getMatcher(String optionKey,String optionValue) { 150 | Map map = new HashMap<>(1); 151 | map.put(optionKey, optionValue); 152 | return is(map); 153 | } 154 | 155 | @Test 156 | public void supports_features_as_direct_bundle_argument() { 157 | 158 | // given 159 | Bundle bundle = new Bundle(); 160 | bundle.putString("features", "someFeature"); 161 | CucumberAndroidJUnitArguments arguments = new CucumberAndroidJUnitArguments(bundle); 162 | 163 | // when 164 | Map cucumberOptions = arguments.getCucumberOptions(); 165 | 166 | assertThat(cucumberOptions, getMatcher(Constants.FEATURES_PROPERTY_NAME,"someFeature")); 167 | } 168 | 169 | @Test 170 | public void supports_multiple_values() { 171 | 172 | // given 173 | Bundle bundle = new Bundle(); 174 | bundle.putString("plugin", "Feature1, Feature2"); 175 | CucumberAndroidJUnitArguments arguments = new CucumberAndroidJUnitArguments(bundle); 176 | 177 | // when 178 | Map cucumberOptions = arguments.getCucumberOptions(); 179 | 180 | // then 181 | assertThat(cucumberOptions, getMatcher(Constants.PLUGIN_PROPERTY_NAME,"Feature1, Feature2")); 182 | } 183 | 184 | @Test 185 | public void supports_spaces_in_values() { 186 | 187 | // given 188 | Bundle bundle = new Bundle(); 189 | bundle.putString("name", "'Name with spaces'"); 190 | CucumberAndroidJUnitArguments arguments = new CucumberAndroidJUnitArguments(bundle); 191 | 192 | // when 193 | Map cucumberOptions = arguments.getCucumberOptions(); 194 | 195 | // then 196 | assertThat(cucumberOptions, getMatcher(Constants.FILTER_NAME_PROPERTY_NAME,"'Name with spaces'")); 197 | } 198 | 199 | @Test 200 | public void supports_cucumber_property() { 201 | 202 | // given 203 | Bundle bundle = new Bundle(); 204 | bundle.putString(Constants.FEATURES_PROPERTY_NAME, "features/path.feature"); 205 | CucumberAndroidJUnitArguments arguments = new CucumberAndroidJUnitArguments(bundle); 206 | 207 | // when 208 | Map cucumberOptions = arguments.getCucumberOptions(); 209 | 210 | // then 211 | assertThat(cucumberOptions, getMatcher(Constants.FEATURES_PROPERTY_NAME,"features/path.feature")); 212 | } 213 | 214 | } 215 | -------------------------------------------------------------------------------- /cucumber-android/src/test/java/io/cucumber/android/CucumberJunitRunnerTest.kt: -------------------------------------------------------------------------------- 1 | package io.cucumber.android 2 | 3 | import android.os.Bundle 4 | import io.cucumber.android.runner.CucumberAndroidJUnitRunner 5 | import io.cucumber.android.shadows.ShadowDexFile 6 | import io.cucumber.junit.CucumberOptions 7 | import org.junit.Assert.* 8 | import org.junit.Before 9 | import org.junit.Test 10 | import org.junit.runner.Description 11 | import org.junit.runner.RunWith 12 | import org.junit.runner.manipulation.Filter 13 | import org.robolectric.RobolectricTestRunner 14 | import kotlin.math.abs 15 | 16 | @RunWith(RobolectricTestRunner::class) 17 | class CucumberJunitRunnerTest { 18 | 19 | private val arguments = Bundle() 20 | 21 | @Before 22 | fun setUp() { 23 | addClassToDex() 24 | arguments.putString(CucumberAndroidJUnitArguments.PublicArgs.OPTIONS_ANNOTATION_LOCATION,"io.cucumber.android") 25 | } 26 | 27 | private fun addClassToDex() { 28 | ShadowDexFile.setEntries( 29 | listOf( 30 | CucumberOptionsClass::class.qualifiedName!! 31 | ) 32 | ) 33 | } 34 | 35 | private fun setArguments(function: Bundle.() -> Unit) { 36 | arguments.apply(function) 37 | CucumberArgumentsProvider.arguments = CucumberAndroidJUnitArguments(arguments).also { it.processArgs() } 38 | } 39 | 40 | @Test 41 | fun `description test count is correct when tests are filtered`() { 42 | 43 | setArguments { } 44 | val numShards = 2 45 | val shardIndex = 0 46 | 47 | val runner = createCucumberJunitRunner() 48 | 49 | runner.filter(object :Filter() { 50 | override fun shouldRun(description: Description): Boolean { 51 | return if (description.isTest) { 52 | abs(description.hashCode()) % numShards == shardIndex 53 | } else true 54 | } 55 | 56 | override fun describe(): String = "sharding" 57 | }) 58 | 59 | assertEquals(2,runner.testCount()) 60 | 61 | val allTests = runner.children.flatMap { it.description.children }.map { it.displayName } 62 | 63 | assertEquals(listOf( 64 | "Scenario Outline 1 1(Feature 1)", 65 | "Scenario Outline 1 2(Feature 2)", 66 | ),allTests) 67 | } 68 | 69 | @Test 70 | fun `single scenario is executed if specified as class and method name`() { 71 | setArguments { 72 | putString("class","Feature 2#Scenario Outline 1 2") 73 | } 74 | val runner = createCucumberJunitRunner() 75 | 76 | assertEquals(1,runner.testCount()) 77 | val scenario = getSingleDescription(runner) 78 | assertEquals("Scenario Outline 1 2(Feature 2)",scenario.displayName) 79 | } 80 | 81 | @Test 82 | fun `single feature is executed if specified as class`() { 83 | setArguments { 84 | putString("class","Feature 2") 85 | } 86 | val runner = createCucumberJunitRunner() 87 | 88 | assertEquals(3,runner.testCount()) 89 | val allScenarios = runner.children.flatMap { it.description.children }.map { it.displayName } 90 | assertEquals(listOf( 91 | "Scenario Outline 1 1(Feature 2)", 92 | "Scenario Outline 1 2(Feature 2)", 93 | "Scenario Outline 1 3(Feature 2)", 94 | ),allScenarios) 95 | } 96 | 97 | @Test 98 | fun `properly parses class argument with multiple values`() { 99 | setArguments { 100 | putString("class","Feature 2#Scenario Outline 1 2,Feature 1,Feature 2#Scenario Outline 1 3") 101 | } 102 | val runner = createCucumberJunitRunner() 103 | 104 | assertEquals(4,runner.testCount()) 105 | val allScenarios = runner.children.flatMap { it.description.children }.map { it.displayName } 106 | assertEquals(listOf( 107 | "Scenario Outline 1 1(Feature 1)", 108 | "Scenario Outline 1 2(Feature 1)", 109 | "Scenario Outline 1 2(Feature 2)", 110 | "Scenario Outline 1 3(Feature 2)", 111 | ),allScenarios) 112 | } 113 | 114 | 115 | @Test 116 | fun `single scenario is executed if specified as feature path with line`() { 117 | setArguments { 118 | putString(CucumberAndroidJUnitArguments.PublicArgs.FEATURES,"assets:features/feature2.feature:11") 119 | } 120 | val runner = createCucumberJunitRunner() 121 | 122 | assertEquals(1,runner.testCount()) 123 | val scenario = getSingleDescription(runner) 124 | assertEquals("Scenario Outline 1 2(Feature 2)",scenario.displayName) 125 | } 126 | 127 | private fun createCucumberJunitRunner() = CucumberJunitRunner(CucumberAndroidJUnitRunner::class.java) 128 | 129 | private fun getSingleDescription(runner: CucumberJunitRunner): Description = runner.children.single().description.children.single() 130 | } 131 | 132 | 133 | @CucumberOptions( 134 | features = ["assets:features"], 135 | ) 136 | class CucumberOptionsClass -------------------------------------------------------------------------------- /cucumber-android/src/test/java/io/cucumber/android/shadows/ExtendedShadowPackageManager.kt: -------------------------------------------------------------------------------- 1 | package io.cucumber.android.shadows 2 | 3 | import android.content.ComponentName 4 | import android.content.pm.InstrumentationInfo 5 | import android.content.pm.PackageManager 6 | import org.robolectric.annotation.Implementation 7 | import org.robolectric.annotation.Implements 8 | import org.robolectric.shadows.ShadowApplicationPackageManager 9 | 10 | @Implements(className = "android.app.ApplicationPackageManager", isInAndroidSdk = false, looseSignatures = true) 11 | class ExtendedShadowPackageManager:ShadowApplicationPackageManager() { 12 | 13 | @Implementation 14 | @Throws(PackageManager.NameNotFoundException::class) 15 | override fun getInstrumentationInfo(className: ComponentName?, flags: Int): InstrumentationInfo { 16 | return InstrumentationInfo() 17 | } 18 | } -------------------------------------------------------------------------------- /cucumber-android/src/test/java/io/cucumber/android/shadows/ShadowDexFile.java: -------------------------------------------------------------------------------- 1 | package io.cucumber.android.shadows; 2 | 3 | import org.robolectric.annotation.Implementation; 4 | import org.robolectric.annotation.Implements; 5 | import org.robolectric.annotation.Resetter; 6 | 7 | import java.util.Collection; 8 | import java.util.Collections; 9 | import java.util.Enumeration; 10 | 11 | import dalvik.system.DexFile; 12 | 13 | @Implements(DexFile.class) 14 | public class ShadowDexFile { 15 | 16 | private static Enumeration entries = Collections.emptyEnumeration(); 17 | 18 | /** @noinspection MethodMayBeStatic*/ 19 | @Implementation 20 | public Enumeration entries() { 21 | return entries; 22 | } 23 | 24 | 25 | public static void setEntries(Collection classes) { 26 | entries = Collections.enumeration(classes); 27 | } 28 | 29 | @Resetter 30 | public static void reset() { 31 | entries = Collections.emptyEnumeration(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /cucumber-android/src/test/java/io/cucumber/cucumberexpressions/AndroidPatternCompilerTest.java: -------------------------------------------------------------------------------- 1 | package io.cucumber.cucumberexpressions; 2 | 3 | import static org.junit.Assert.assertFalse; 4 | import static org.junit.Assert.assertTrue; 5 | 6 | import org.junit.Test; 7 | 8 | import java.util.ServiceLoader; 9 | import java.util.regex.Pattern; 10 | 11 | public class AndroidPatternCompilerTest { 12 | 13 | @Test 14 | public void compiles_pattern_only_with_supported_flag() { 15 | 16 | AndroidPatternCompiler compiler = (AndroidPatternCompiler) ServiceLoader.load(PatternCompiler.class).iterator().next(); 17 | 18 | Pattern pattern = compiler.compile("HELLO", Pattern.UNICODE_CHARACTER_CLASS); 19 | 20 | assertFalse(pattern.matcher("hello").find()); 21 | 22 | 23 | pattern = compiler.compile("HELLO", Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CHARACTER_CLASS); 24 | 25 | assertTrue(pattern.matcher("hello").find()); 26 | } 27 | } -------------------------------------------------------------------------------- /cucumber-android/src/test/resources/robolectric.properties: -------------------------------------------------------------------------------- 1 | shadows=io.cucumber.android.shadows.ExtendedShadowPackageManager,io.cucumber.android.shadows.ShadowDexFile -------------------------------------------------------------------------------- /cucumber-junit-rules-support/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | apply plugin: 'kotlin-android' 3 | 4 | addAndroidConfig() 5 | addLibraryPublishing('Cucumber-JVM: JUnit Rules Support') 6 | 7 | dependencies { 8 | 9 | api "io.cucumber:cucumber-java:$cucumber_javaVersion" 10 | api "io.cucumber:cucumber-junit:$cucumber_javaVersion" 11 | api 'junit:junit:4.13.2' 12 | 13 | testImplementation "org.robolectric:robolectric:$robolectricVersion" 14 | testImplementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" 15 | testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version" 16 | 17 | } 18 | 19 | -------------------------------------------------------------------------------- /cucumber-junit-rules-support/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /cucumber-junit-rules-support/src/main/java/io/cucumber/junit/TestRuleAccessor.java: -------------------------------------------------------------------------------- 1 | package io.cucumber.junit; 2 | 3 | import org.junit.rules.TestRule; 4 | 5 | import java.lang.reflect.InvocationTargetException; 6 | 7 | public interface TestRuleAccessor { 8 | 9 | TestRule getRule(Object obj) throws IllegalAccessException, InvocationTargetException; 10 | 11 | int getOrder(); 12 | } 13 | -------------------------------------------------------------------------------- /cucumber-junit-rules-support/src/main/java/io/cucumber/junit/TestRulesData.java: -------------------------------------------------------------------------------- 1 | package io.cucumber.junit; 2 | 3 | import java.util.List; 4 | 5 | import io.cucumber.tagexpressions.Expression; 6 | import io.cucumber.tagexpressions.TagExpressionParser; 7 | 8 | public class TestRulesData { 9 | 10 | private final Class declaringClass; 11 | private final Object declaringObject; 12 | private final List accessors; 13 | private boolean useAsTestClassInDescription; 14 | private Expression tagExpression; 15 | 16 | public TestRulesData(boolean useAsTestClassInDescription, Class declaringClass, List accessors, String tagExpression) { 17 | this(useAsTestClassInDescription, declaringClass, null, accessors, parse(tagExpression)); 18 | } 19 | 20 | private static Expression parse(String tagExpression) { 21 | return TagExpressionParser.parse(tagExpression); 22 | } 23 | 24 | public TestRulesData(boolean useAsTestClassInDescription, Object declaringObject, List accessors, Expression tagExpression) { 25 | this(useAsTestClassInDescription, declaringObject.getClass(), declaringObject, accessors, tagExpression); 26 | } 27 | 28 | public TestRulesData(boolean useAsTestClassInDescription, Object declaringObject, List accessors, String tagExpression) { 29 | this(useAsTestClassInDescription, declaringObject.getClass(), declaringObject, accessors, parse(tagExpression)); 30 | } 31 | 32 | private TestRulesData(boolean useAsTestClassInDescription, Class declaringClass, Object declaringObject, List accessors, Expression tagExpression) { 33 | this.useAsTestClassInDescription = useAsTestClassInDescription; 34 | this.declaringClass = declaringClass; 35 | this.declaringObject = declaringObject; 36 | this.accessors = accessors; 37 | this.tagExpression = tagExpression; 38 | } 39 | 40 | public Class getDeclaringClass() { 41 | return declaringClass; 42 | } 43 | 44 | public List getAccessors() { 45 | return accessors; 46 | } 47 | 48 | public Object getDeclaringObject() { 49 | return declaringObject; 50 | } 51 | 52 | public boolean useAsTestClassInDescription() { 53 | return useAsTestClassInDescription; 54 | } 55 | 56 | public Expression getTagExpression() { 57 | return tagExpression; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /cucumber-junit-rules-support/src/main/java/io/cucumber/junit/TestRulesExecutor.java: -------------------------------------------------------------------------------- 1 | package io.cucumber.junit; 2 | 3 | import android.util.Pair; 4 | 5 | import org.junit.rules.RunRules; 6 | import org.junit.rules.TestRule; 7 | import org.junit.runner.Description; 8 | import org.junit.runners.model.Statement; 9 | 10 | import java.lang.reflect.InvocationTargetException; 11 | import java.util.ArrayList; 12 | import java.util.Collections; 13 | import java.util.List; 14 | import java.util.concurrent.CountDownLatch; 15 | import java.util.concurrent.ExecutionException; 16 | import java.util.concurrent.ExecutorService; 17 | import java.util.concurrent.Future; 18 | import java.util.concurrent.TimeUnit; 19 | import java.util.concurrent.TimeoutException; 20 | import java.util.concurrent.atomic.AtomicReference; 21 | 22 | import io.cucumber.core.exception.CucumberException; 23 | import kotlin.collections.CollectionsKt; 24 | 25 | public class TestRulesExecutor { 26 | 27 | private final List rulesHolders; 28 | private CountDownLatch wrappedStatementLatch = new CountDownLatch(1); 29 | private CountDownLatch rulesExecutionLatch = new CountDownLatch(1); 30 | private ExecutorService executorService; 31 | private Future rulesFuture; 32 | private final TimeUnit waitTimeUnit; 33 | private final int maxWaitTime; 34 | 35 | public TestRulesExecutor(List rulesHolders, ExecutorService executorService) { 36 | this(rulesHolders, executorService, 10,TimeUnit.MINUTES); 37 | } 38 | 39 | TestRulesExecutor(List rulesHolders, ExecutorService executorService, int maxWaitTime, TimeUnit waitTimeUnit) { 40 | this.rulesHolders = rulesHolders; 41 | this.executorService = executorService; 42 | this.waitTimeUnit = waitTimeUnit; 43 | this.maxWaitTime = maxWaitTime; 44 | } 45 | 46 | public void startRules(Description description, List tags) { 47 | List filteredRules = CollectionsKt.filter(rulesHolders, testRulesData -> testRulesData.getTagExpression().evaluate(tags)); 48 | if (filteredRules.isEmpty()) { 49 | return; 50 | } 51 | AtomicReference throwable = new AtomicReference<>(); 52 | try { 53 | List> rulesWithOrders = new ArrayList<>(filteredRules.size()); 54 | for (TestRulesData rulesData : filteredRules) { 55 | Object obj = rulesData.getDeclaringObject(); 56 | List accessors = rulesData.getAccessors(); 57 | 58 | for (TestRuleAccessor accessor : accessors) { 59 | TestRule rule = getTestRule(rulesData, obj, accessor); 60 | rulesWithOrders.add(Pair.create(accessor.getOrder(),rule)); 61 | } 62 | } 63 | List rules = getTestRules(rulesWithOrders); 64 | 65 | rulesFuture = executorService.submit(getTask(description, throwable, rules)); 66 | if (!rulesExecutionLatch.await(maxWaitTime,waitTimeUnit)){ 67 | throw new TimeoutException(String.format("Unable to start rules within %d %s",maxWaitTime,waitTimeUnit)); 68 | } 69 | } catch (Throwable e) { 70 | throw new CucumberException(e); 71 | } 72 | if (throwable.get()!=null){ 73 | throw new CucumberException(throwable.get()); 74 | } 75 | } 76 | 77 | private Runnable getTask(Description description, AtomicReference throwable, List rules) { 78 | return new Runnable() { 79 | @Override 80 | public void run() { 81 | try { 82 | RunRules runRules = new RunRules(new Statement() { 83 | @Override 84 | public void evaluate() throws Throwable { 85 | rulesExecutionLatch.countDown(); 86 | wrappedStatementLatch.await(); 87 | } 88 | }, rules, description); 89 | runRules.evaluate(); 90 | } catch (Throwable t) { 91 | if (rulesExecutionLatch.getCount() != 0) { 92 | //exception on rule 93 | rulesExecutionLatch.countDown(); 94 | } 95 | throwable.set(t); 96 | } 97 | } 98 | }; 99 | } 100 | 101 | private TestRule getTestRule(TestRulesData rulesData, Object obj, TestRuleAccessor accessor) throws IllegalAccessException, InvocationTargetException { 102 | TestRule rule = accessor.getRule(obj); 103 | if (rulesData.useAsTestClassInDescription()){ 104 | TestRule finalRule = rule; 105 | rule = (base, description1) -> finalRule.apply(base, Description.createTestDescription(rulesData.getDeclaringClass(), description1.getMethodName())); 106 | } 107 | return rule; 108 | } 109 | 110 | private static List getTestRules(List> rulesWithOrders) { 111 | //noinspection ComparatorCombinators 112 | Collections.sort(rulesWithOrders,(o1, o2) -> o1.first.compareTo(o2.first) ); 113 | List rules = new ArrayList<>(rulesWithOrders.size()); 114 | 115 | for (Pair rule : rulesWithOrders) { 116 | rules.add(rule.second); 117 | } 118 | return rules; 119 | } 120 | 121 | public void stopRules() { 122 | if (rulesFuture == null) { 123 | return; 124 | } 125 | wrappedStatementLatch.countDown(); 126 | try { 127 | rulesFuture.get(maxWaitTime, waitTimeUnit); 128 | } catch (ExecutionException e) { 129 | throw new CucumberException(e); 130 | } catch (InterruptedException e) { 131 | throw new CucumberException(e); 132 | } catch (TimeoutException e) { 133 | throw new CucumberException(String.format("Unable to stop rules within %d %s",maxWaitTime,waitTimeUnit),e); 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /cucumber-junit-rules-support/src/test/java/io/cucumber/junit/TestRulesExecutorTest.kt: -------------------------------------------------------------------------------- 1 | package io.cucumber.junit 2 | 3 | import io.cucumber.core.exception.CucumberException 4 | import org.junit.After 5 | import org.junit.Test 6 | import org.junit.rules.TestRule 7 | import org.junit.runner.Description 8 | import org.junit.runner.RunWith 9 | import org.junit.runners.model.Statement 10 | import org.robolectric.RobolectricTestRunner 11 | import java.util.concurrent.Executors 12 | import java.util.concurrent.TimeUnit 13 | import kotlin.test.assertFailsWith 14 | 15 | @RunWith(RobolectricTestRunner::class) 16 | class TestRulesExecutorTest { 17 | private val service = Executors.newSingleThreadExecutor() 18 | 19 | @After 20 | fun tearDown() { 21 | service.shutdown() 22 | } 23 | 24 | @Test 25 | fun `does not wait forever for deadlock in rule teardown`() { 26 | 27 | val rulesData = testRulesData(after = {Thread.sleep(10_000)}) 28 | val rulesExecutor = TestRulesExecutor(rulesData, service, 1,TimeUnit.SECONDS) 29 | 30 | rulesExecutor.startRules(Description.createTestDescription(javaClass,"test"), emptyList()) 31 | assertFailsWith(CucumberException::class) { rulesExecutor.stopRules() } 32 | } 33 | 34 | @Test 35 | fun `does not wait forever for deadlock in rule setup`() { 36 | 37 | val rulesData = testRulesData(before = {Thread.sleep(10_000)}) 38 | val rulesExecutor = TestRulesExecutor(rulesData, service, 1,TimeUnit.SECONDS) 39 | 40 | assertFailsWith(CucumberException::class) { rulesExecutor.startRules(Description.createTestDescription(javaClass,"test"), emptyList()) } 41 | 42 | } 43 | 44 | private fun testRulesData(before:() -> Unit = {},after:() -> Unit = {}): List { 45 | val rulesData = listOf(TestRulesData(false, this, listOf(object : TestRuleAccessor { 46 | override fun getRule(obj: Any?): TestRule = TestRule { base, _ -> 47 | object : Statement() { 48 | override fun evaluate() { 49 | before() 50 | base.evaluate() 51 | after() 52 | } 53 | } 54 | } 55 | 56 | override fun getOrder(): Int = 0 57 | }), "")) 58 | return rulesData 59 | } 60 | } -------------------------------------------------------------------------------- /cucumber-junit-rules-support/src/test/resources/robolectric.properties: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cucumber/cucumber-android/0c08b28eefa4eb23fb60fb674b36e956931669bf/cucumber-junit-rules-support/src/test/resources/robolectric.properties -------------------------------------------------------------------------------- /cukeulator/.gitignore: -------------------------------------------------------------------------------- 1 | # Built application files 2 | /*/build/ 3 | build/ 4 | 5 | # Crashlytics configuations 6 | com_crashlytics_export_strings.xml 7 | 8 | # Local configuration file (sdk path, etc) 9 | local.properties 10 | 11 | # Gradle generated files 12 | .gradle/ 13 | 14 | # Signing files 15 | .signing/ 16 | 17 | # User-specific configurations 18 | .idea/ 19 | *.iml 20 | 21 | # OS-specific files 22 | .DS_Store 23 | .DS_Store? 24 | ._* 25 | .Spotlight-V100 26 | .Trashes 27 | ehthumbs.db 28 | Thumbs.db 29 | -------------------------------------------------------------------------------- /cukeulator/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'jacoco' 3 | apply plugin: "kotlin-android" 4 | apply plugin: 'com.google.devtools.ksp' 5 | apply plugin: 'dagger.hilt.android.plugin' 6 | apply plugin: 'org.jetbrains.kotlin.plugin.compose' 7 | 8 | 9 | // ================================================================== 10 | // Android configuration 11 | // ================================================================== 12 | 13 | addAndroidConfig() 14 | 15 | android { 16 | 17 | namespace = "cucumber.cukeulator" 18 | 19 | defaultConfig { 20 | minSdkVersion 21 21 | multiDexEnabled true 22 | applicationId "cucumber.cukeulator" 23 | testApplicationId "cucumber.cukeulator.test" 24 | testInstrumentationRunner "cucumber.cukeulator.test.CukeulatorAndroidJUnitRunner" 25 | //testInstrumentationRunnerArguments = [ 26 | // cucumberUseAndroidJUnitRunner: getProperty("cucumberUseAndroidJUnitRunner"), 27 | // uncomment this to clear app data before each test when running with orchestrator 28 | // clearPackageData: 'true' 29 | //] 30 | } 31 | 32 | buildFeatures { 33 | // Enables Jetpack Compose for this module 34 | compose true 35 | viewBinding = true 36 | } 37 | 38 | compileOptions { 39 | // Flag to enable support for the new language APIs 40 | coreLibraryDesugaringEnabled true 41 | } 42 | 43 | packagingOptions { 44 | exclude 'LICENSE.txt' 45 | exclude 'META-INF/AL2.0' 46 | exclude 'META-INF/LGPL2.1' 47 | } 48 | 49 | testOptions { 50 | animationsDisabled findProperty("disableAnimations") == "true" 51 | // uncomment this to run tests with orchestrator 52 | // execution 'ANDROIDX_TEST_ORCHESTRATOR' 53 | } 54 | 55 | } 56 | 57 | // ================================================================== 58 | // Project dependencies 59 | // ================================================================== 60 | 61 | 62 | dependencies { 63 | 64 | def composeBom = platform('androidx.compose:compose-bom:2024.06.00') 65 | implementation(composeBom) 66 | implementation 'androidx.appcompat:appcompat:1.7.0' 67 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" 68 | implementation 'androidx.activity:activity-compose:1.9.0' 69 | implementation "androidx.compose.ui:ui-tooling" 70 | implementation "androidx.compose.runtime:runtime" 71 | implementation "androidx.compose.material:material" 72 | implementation "com.google.dagger:hilt-android:$hilt_version" 73 | ksp "com.google.dagger:hilt-compiler:$hilt_version" 74 | 75 | androidTestImplementation(composeBom) 76 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' 77 | androidTestImplementation 'androidx.test:core:1.6.1' 78 | androidTestImplementation 'androidx.test:rules:1.6.1' 79 | androidTestImplementation "androidx.compose.ui:ui-test-junit4" 80 | androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version" 81 | kspAndroidTest "com.google.dagger:hilt-android-compiler:$hilt_version" 82 | 83 | androidTestUtil 'androidx.test:orchestrator:1.5.0' 84 | 85 | androidTestImplementation project(":cucumber-android") 86 | androidTestImplementation project(":cucumber-android-hilt") 87 | 88 | //desugaring 89 | coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.0.4" 90 | 91 | } -------------------------------------------------------------------------------- /cukeulator/src/androidTest/assets/features/extra/calculate.feature: -------------------------------------------------------------------------------- 1 | Feature: Calculate a result 2 | Perform an arithmetic operation on two numbers using a mathematical operator 3 | """The purpose of this feature is to illustrate how existing step-definitions 4 | can be efficiently reused.""" 5 | 6 | Scenario Outline: Enter a digit an operator and another digit 7 | Given I have a CalculatorActivity 8 | When I press 9 | And I press 10 | And I press 11 | And I press = 12 | Then I should see "" on the display 13 | 14 | Examples: 15 | | num1 | num2 | op | result | 16 | | 9 | 8 | + | 17.0 | 17 | | 7 | 6 | – | 1.0 | 18 | | 5 | 4 | x | 20.0 | 19 | | 3 | 2 | / | 1.5 | 20 | | 1 | 0 | / | Infinity | 21 | -------------------------------------------------------------------------------- /cukeulator/src/androidTest/assets/features/extra/compose.feature: -------------------------------------------------------------------------------- 1 | Feature: Compose Hello world 2 | 3 | Scenario: Show hello world 4 | When I open compose activity 5 | Then "test hello world" text is presented 6 | 7 | Scenario Outline: Show hello world with different text 8 | When I open compose activity with "" 9 | Then "" text is presented 10 | 11 | Examples: 12 | | text | 13 | | some text 1 | 14 | | some text 2 | 15 | 16 | 17 | @CustomComposable 18 | Scenario: Custom composable 19 | When I show custom composable 20 | Then custom "Custom composable" text is presented -------------------------------------------------------------------------------- /cukeulator/src/androidTest/assets/features/extra/hilt.feature: -------------------------------------------------------------------------------- 1 | Feature: Android Hilt 2 | 3 | Scenario: Injects fake hilt service 4 | Then greeting service returns "test hello world" 5 | -------------------------------------------------------------------------------- /cukeulator/src/androidTest/assets/features/operations/addition.feature: -------------------------------------------------------------------------------- 1 | Feature: Add two numbers 2 | Calculate the sum of two numbers which consist of one or more digits 3 | 4 | Scenario Outline: Enter one digit per number and press = 5 | Given I have a CalculatorActivity 6 | When I press 7 | And I press + 8 | And I press 9 | And I press = 10 | Then I should see "" on the display 11 | 12 | Examples: 13 | | num1 | num2 | sum | 14 | | 0 | 0 | 0.0 | 15 | | 0 | 1 | 1.0 | 16 | | 1 | 1 | 2.0 | 17 | 18 | Scenario Outline: Enter two digits per number and press = 19 | Given I have a CalculatorActivity 20 | When I press 21 | When I press 22 | And I press + 23 | And I press 24 | And I press 25 | And I press = 26 | Then I should see "" on the display 27 | 28 | Examples: 29 | | num1 | num2 | num3 | num4 | sum | 30 | | 0 | 0 | 2 | 0 | 20.0 | 31 | | 9 | 8 | 7 | 6 | 174.0 | 32 | -------------------------------------------------------------------------------- /cukeulator/src/androidTest/assets/features/operations/division.feature: -------------------------------------------------------------------------------- 1 | Feature: Divide two numbers 2 | Calculate the quotient of two numbers which consist of one or more digits 3 | 4 | Scenario Outline: Enter one digit per number and press = 5 | Given I have a CalculatorActivity 6 | When I press 7 | And I press / 8 | And I press 9 | And I press = 10 | Then I should see "" on the display 11 | 12 | Examples: 13 | | num1 | num2 | quotient | 14 | | 0 | 0 | NaN | 15 | | 1 | 0 | Infinity | 16 | | 1 | 2 | 0.5 | 17 | 18 | Scenario Outline: Enter two digits per number and press = 19 | Given I have a CalculatorActivity 20 | When I press 21 | When I press 22 | And I press / 23 | And I press 24 | And I press 25 | And I press = 26 | Then I should see "" on the display 27 | 28 | Examples: 29 | | num1 | num2 | num3 | num4 | quotient | 30 | | 2 | 2 | 2 | 2 | 1.0 | 31 | | 2 | 0 | 1 | 0 | 2.0 | 32 | -------------------------------------------------------------------------------- /cukeulator/src/androidTest/assets/features/operations/multiplication.feature: -------------------------------------------------------------------------------- 1 | Feature: Multiply two numbers 2 | Calculate the product of two numbers which consist of one or more digits 3 | 4 | Scenario Outline: Enter one digit per number and press = 5 | Given I have a CalculatorActivity 6 | When I press 7 | And I press x 8 | And I press 9 | And I press = 10 | Then I should see "" on the display 11 | 12 | Examples: 13 | | num1 | num2 | product | 14 | | 0 | 0 | 0.0 | 15 | | 0 | 1 | 0.0 | 16 | | 1 | 2 | 2.0 | 17 | 18 | Scenario Outline: Enter two digits per number and press = 19 | Given I have a CalculatorActivity 20 | When I press 21 | When I press 22 | And I press x 23 | And I press 24 | And I press 25 | And I press = 26 | Then I should see "" on the display 27 | 28 | Examples: 29 | | num1 | num2 | num3 | num4 | product | 30 | | 2 | 2 | 2 | 2 | 484.0 | 31 | | 2 | 0 | 1 | 0 | 200.0 | 32 | -------------------------------------------------------------------------------- /cukeulator/src/androidTest/assets/features/operations/subtraction.feature: -------------------------------------------------------------------------------- 1 | Feature: Subtract two numbers 2 | Calculate the difference of two numbers which consist of one or more digits 3 | 4 | Scenario Outline: Enter one digit per number and press = 5 | Given I have a CalculatorActivity 6 | When I press 7 | And I press – 8 | And I press 9 | And I press = 10 | Then I should see "" on the display 11 | 12 | Examples: 13 | | num1 | num2 | delta | 14 | | 0 | 0 | 0.0 | 15 | | 0 | 1 | -1.0 | 16 | | 1 | 2 | -1.0 | 17 | 18 | Scenario Outline: Enter two digits per number and press = 19 | Given I have a CalculatorActivity 20 | When I press 21 | When I press 22 | And I press – 23 | And I press 24 | And I press 25 | And I press = 26 | Then I should see "" on the display 27 | 28 | Examples: 29 | | num1 | num2 | num3 | num4 | delta | 30 | | 2 | 2 | 2 | 2 | 0.0 | 31 | | 2 | 0 | 1 | 0 | 10.0 | 32 | -------------------------------------------------------------------------------- /cukeulator/src/androidTest/java/cucumber/cukeulator/test/ActivityScenarioHolder.kt: -------------------------------------------------------------------------------- 1 | package cucumber.cukeulator.test 2 | 3 | import android.app.Activity 4 | import android.content.Intent 5 | import androidx.test.core.app.ActivityScenario 6 | import io.cucumber.java.After 7 | 8 | class ActivityScenarioHolder { 9 | 10 | private var scenario:ActivityScenario<*>? = null 11 | 12 | fun launch(intent:Intent){ 13 | scenario = ActivityScenario.launch(intent) 14 | } 15 | 16 | /** 17 | * Close activity after scenario 18 | */ 19 | @After 20 | fun close(){ 21 | scenario?.close() 22 | } 23 | } -------------------------------------------------------------------------------- /cukeulator/src/androidTest/java/cucumber/cukeulator/test/BaseKotlinSteps.kt: -------------------------------------------------------------------------------- 1 | package cucumber.cukeulator.test 2 | 3 | import androidx.compose.ui.test.SemanticsMatcher 4 | import androidx.compose.ui.test.SemanticsNodeInteraction 5 | import androidx.compose.ui.test.SemanticsNodeInteractionCollection 6 | import androidx.compose.ui.test.SemanticsNodeInteractionsProvider 7 | import cucumber.cukeulator.GreetingService 8 | import javax.inject.Inject 9 | 10 | abstract class BaseKotlinSteps: SemanticsNodeInteractionsProvider { 11 | 12 | @Inject 13 | lateinit var composeRuleHolder: ComposeRuleHolder 14 | 15 | @Inject 16 | lateinit var greetingService: GreetingService 17 | 18 | override fun onAllNodes(matcher: SemanticsMatcher, useUnmergedTree: Boolean): SemanticsNodeInteractionCollection { 19 | return composeRuleHolder.composeRule.onAllNodes(matcher, useUnmergedTree) 20 | } 21 | 22 | override fun onNode(matcher: SemanticsMatcher, useUnmergedTree: Boolean): SemanticsNodeInteraction { 23 | return composeRuleHolder.composeRule.onNode(matcher, useUnmergedTree) 24 | } 25 | } -------------------------------------------------------------------------------- /cukeulator/src/androidTest/java/cucumber/cukeulator/test/CalculatorActivitySteps.java: -------------------------------------------------------------------------------- 1 | package cucumber.cukeulator.test; 2 | 3 | import static androidx.test.espresso.Espresso.onView; 4 | import static androidx.test.espresso.action.ViewActions.click; 5 | import static androidx.test.espresso.matcher.ViewMatchers.withId; 6 | import static org.junit.Assert.assertNotNull; 7 | 8 | import android.content.Intent; 9 | 10 | import androidx.test.core.app.ActivityScenario; 11 | import androidx.test.platform.app.InstrumentationRegistry; 12 | 13 | import cucumber.cukeulator.CalculatorActivity; 14 | import cucumber.cukeulator.R; 15 | import io.cucumber.java.en.Given; 16 | import io.cucumber.java.en.When; 17 | 18 | /** 19 | * We use {@link ActivityScenario} in order to have access to methods like getActivity 20 | * and getInstrumentation. 21 | *

22 | * The CucumberOptions annotation is mandatory for exactly one of the classes in the test project. 23 | * Only the first annotated class that is found will be used, others are ignored. If no class is 24 | * annotated, an exception is thrown. 25 | *

26 | * The options need to at least specify features = "features". Features must be placed inside 27 | * assets/features/ of the test project (or a subdirectory thereof). 28 | */ 29 | public class CalculatorActivitySteps { 30 | 31 | private ActivityScenarioHolder scenario; 32 | private CalculatorActivity calculatorActivity; 33 | 34 | public CalculatorActivitySteps(SomeDependency dependency,ActivityScenarioHolder scenario) { 35 | assertNotNull(dependency); 36 | this.scenario = scenario; 37 | } 38 | 39 | 40 | @Given("I have a CalculatorActivity") 41 | public void I_have_a_CalculatorActivity() { 42 | scenario.launch(new Intent(InstrumentationRegistry.getInstrumentation().getTargetContext(),CalculatorActivity.class)); 43 | } 44 | 45 | @When("I press {digit}") 46 | public void I_press_d(final int d) { 47 | switch (d) { 48 | case 0: 49 | performClick(R.id.btn_d_0); 50 | break; 51 | case 1: 52 | performClick(R.id.btn_d_1); 53 | break; 54 | case 2: 55 | performClick(R.id.btn_d_2); 56 | break; 57 | case 3: 58 | performClick(R.id.btn_d_3); 59 | break; 60 | case 4: 61 | performClick(R.id.btn_d_4); 62 | break; 63 | case 5: 64 | performClick(R.id.btn_d_5); 65 | break; 66 | case 6: 67 | performClick(R.id.btn_d_6); 68 | break; 69 | case 7: 70 | performClick(R.id.btn_d_7); 71 | break; 72 | case 8: 73 | performClick(R.id.btn_d_8); 74 | break; 75 | case 9: 76 | performClick(R.id.btn_d_9); 77 | break; 78 | } 79 | } 80 | 81 | @When("I press {operator}") 82 | public void I_press_op(final char op) { 83 | switch (op) { 84 | case '+': 85 | performClick(R.id.btn_op_add); 86 | break; 87 | case '–': 88 | performClick(R.id.btn_op_subtract); 89 | break; 90 | case 'x': 91 | performClick(R.id.btn_op_multiply); 92 | break; 93 | case '/': 94 | performClick(R.id.btn_op_divide); 95 | break; 96 | case '=': 97 | performClick(R.id.btn_op_equals); 98 | break; 99 | } 100 | } 101 | 102 | private void performClick(int id) { 103 | onView(withId(id)).perform(click()); 104 | } 105 | 106 | } 107 | -------------------------------------------------------------------------------- /cukeulator/src/androidTest/java/cucumber/cukeulator/test/ComposeRuleHolder.kt: -------------------------------------------------------------------------------- 1 | package cucumber.cukeulator.test 2 | 3 | import androidx.compose.ui.test.junit4.createComposeRule 4 | import androidx.compose.ui.test.junit4.createEmptyComposeRule 5 | import io.cucumber.junit.WithJunitRule 6 | import org.junit.Rule 7 | import javax.inject.Inject 8 | import javax.inject.Singleton 9 | 10 | @WithJunitRule("not @CustomComposable") 11 | @Singleton 12 | class ComposeRuleHolder @Inject constructor() { 13 | 14 | @get:Rule(order = 1) 15 | val composeRule = createEmptyComposeRule() 16 | } 17 | 18 | @WithJunitRule("@CustomComposable") 19 | @Singleton 20 | class CustomComposableRuleHolder @Inject constructor(){ 21 | 22 | @get:Rule(order = 1) 23 | val composeRule = createComposeRule() 24 | } -------------------------------------------------------------------------------- /cukeulator/src/androidTest/java/cucumber/cukeulator/test/CukeulatorAndroidJUnitRunner.java: -------------------------------------------------------------------------------- 1 | package cucumber.cukeulator.test; 2 | 3 | import android.app.Application; 4 | import android.content.Context; 5 | 6 | import dagger.hilt.android.testing.HiltTestApplication; 7 | import io.cucumber.android.runner.CucumberAndroidJUnitRunner; 8 | import io.cucumber.junit.CucumberOptions; 9 | 10 | /** 11 | * The CucumberOptions annotation is mandatory for exactly one of the classes in the test project. 12 | * Only the first annotated class that is found will be used, others are ignored. If no class is 13 | * annotated, an exception is thrown. This annotation does not have to placed in runner class 14 | */ 15 | @CucumberOptions( 16 | features = "features" 17 | // ,useFileNameCompatibleName = true 18 | ) 19 | public class CukeulatorAndroidJUnitRunner extends CucumberAndroidJUnitRunner { 20 | 21 | @Override 22 | public Application newApplication(ClassLoader cl, String className, Context context) throws ClassNotFoundException, IllegalAccessException, InstantiationException { 23 | return super.newApplication(cl, HiltTestApplication.class.getName(), context); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /cukeulator/src/androidTest/java/cucumber/cukeulator/test/CustomComposableSteps.kt: -------------------------------------------------------------------------------- 1 | package cucumber.cukeulator.test 2 | 3 | import androidx.compose.material.Text 4 | import androidx.compose.ui.test.SemanticsNodeInteractionsProvider 5 | import androidx.compose.ui.test.assertIsDisplayed 6 | import androidx.compose.ui.test.onNodeWithText 7 | import dagger.hilt.android.testing.HiltAndroidTest 8 | import io.cucumber.java.en.Then 9 | import io.cucumber.java.en.When 10 | 11 | @HiltAndroidTest 12 | class CustomComposableSteps( 13 | val composeRuleHolder: CustomComposableRuleHolder, 14 | ) : SemanticsNodeInteractionsProvider by composeRuleHolder.composeRule { 15 | 16 | 17 | @When("^I show custom composable$") 18 | fun iShowCustomComposable() { 19 | composeRuleHolder.composeRule.setContent { 20 | Text(text = "Custom composable") 21 | } 22 | } 23 | 24 | @Then("^custom \"([^\"]*)\" text is presented$") 25 | fun customTextIsPresented(arg0: String) { 26 | onNodeWithText(arg0).assertIsDisplayed() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /cukeulator/src/androidTest/java/cucumber/cukeulator/test/FakeHiltModule.kt: -------------------------------------------------------------------------------- 1 | package cucumber.cukeulator.test 2 | 3 | import cucumber.cukeulator.GreetingService 4 | import cucumber.cukeulator.HiltModule 5 | import dagger.Module 6 | import dagger.Provides 7 | import dagger.hilt.components.SingletonComponent 8 | import dagger.hilt.testing.TestInstallIn 9 | import javax.inject.Singleton 10 | 11 | @Module 12 | @TestInstallIn( 13 | components = [SingletonComponent::class], 14 | replaces = [HiltModule::class] 15 | ) 16 | class FakeHiltModule { 17 | 18 | @Singleton 19 | @Provides 20 | fun service(): GreetingService = GreetingService { "test hello world" } 21 | } -------------------------------------------------------------------------------- /cukeulator/src/androidTest/java/cucumber/cukeulator/test/InstrumentationNonCucumberTest.java: -------------------------------------------------------------------------------- 1 | package cucumber.cukeulator.test; 2 | 3 | import androidx.test.core.app.ActivityScenario; 4 | import androidx.test.filters.SmallTest; 5 | import cucumber.cukeulator.CalculatorActivity; 6 | import cucumber.cukeulator.R; 7 | import org.junit.Before; 8 | import org.junit.Test; 9 | 10 | import static androidx.test.espresso.Espresso.onView; 11 | import static androidx.test.espresso.action.ViewActions.click; 12 | import static androidx.test.espresso.assertion.ViewAssertions.matches; 13 | import static androidx.test.espresso.matcher.ViewMatchers.withId; 14 | import static androidx.test.espresso.matcher.ViewMatchers.withText; 15 | 16 | /** 17 | * The aim of this test is to make sure that it is possible to run non cucumber instrumentation tests. 18 | */ 19 | public class InstrumentationNonCucumberTest { 20 | 21 | private ActivityScenario scenario; 22 | 23 | @Before 24 | public void setUp() throws Exception { 25 | scenario = ActivityScenario.launch(CalculatorActivity.class); 26 | } 27 | 28 | @SmallTest 29 | @Test 30 | public void assert_that_click_on_0_is_visible_in_the_text_cal_display() { 31 | onView(withId(R.id.btn_d_0)) 32 | .perform(click()); 33 | 34 | onView(withId(R.id.txt_calc_display)) 35 | .check(matches(withText("0"))); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /cukeulator/src/androidTest/java/cucumber/cukeulator/test/KotlinSteps.kt: -------------------------------------------------------------------------------- 1 | package cucumber.cukeulator.test 2 | 3 | import androidx.compose.ui.test.assertIsDisplayed 4 | import androidx.compose.ui.test.onNodeWithText 5 | import androidx.test.core.app.ApplicationProvider 6 | import androidx.test.espresso.Espresso 7 | import androidx.test.espresso.assertion.ViewAssertions 8 | import androidx.test.espresso.matcher.ViewMatchers 9 | import androidx.test.espresso.matcher.ViewMatchers.withId 10 | import androidx.test.platform.app.InstrumentationRegistry 11 | import cucumber.cukeulator.ComposeTestActivity 12 | import cucumber.cukeulator.R 13 | import dagger.hilt.android.testing.HiltAndroidTest 14 | import io.cucumber.java.en.Then 15 | import io.cucumber.java.en.When 16 | import org.junit.Assert 17 | 18 | @HiltAndroidTest 19 | class KotlinSteps( 20 | val scenarioHolder: ActivityScenarioHolder 21 | ): BaseKotlinSteps() { 22 | 23 | 24 | 25 | @Then("I should see {string} on the display") 26 | fun I_should_see_s_on_the_display(s: String?) { 27 | Espresso.onView(withId(R.id.txt_calc_display)).check(ViewAssertions.matches(ViewMatchers.withText(s))) 28 | } 29 | 30 | @When("^I open compose activity$") 31 | fun iOpenComposeActivity() { 32 | iOpenComposeActivityWith(null) 33 | } 34 | 35 | @Then("^\"([^\"]*)\" text is presented$") 36 | fun textIsPresented(arg0: String) { 37 | onNodeWithText(arg0).assertIsDisplayed() 38 | } 39 | 40 | @When("^I open compose activity with \"([^\"]*)\"$") 41 | fun iOpenComposeActivityWith(arg0: String?) { 42 | val instrumentation = InstrumentationRegistry.getInstrumentation() 43 | scenarioHolder.launch(ComposeTestActivity.create(instrumentation.targetContext,arg0)) 44 | } 45 | 46 | @Then("^greeting service returns \"([^\"]*)\"$") 47 | fun greetingServiceReturns(arg0: String) { 48 | Assert.assertEquals(arg0,greetingService.greeting(ApplicationProvider.getApplicationContext())) 49 | } 50 | } -------------------------------------------------------------------------------- /cukeulator/src/androidTest/java/cucumber/cukeulator/test/SomeClassWithUnsupportedApi.java: -------------------------------------------------------------------------------- 1 | package cucumber.cukeulator.test; 2 | 3 | import java.util.Comparator; 4 | import java.util.function.Function; 5 | import java.util.function.ToDoubleFunction; 6 | import java.util.function.ToIntFunction; 7 | import java.util.function.ToLongFunction; 8 | 9 | public class SomeClassWithUnsupportedApi implements Comparator { 10 | 11 | 12 | @Override 13 | public int compare(Integer o1, Integer o2) { 14 | return 0; 15 | } 16 | 17 | @Override 18 | public Comparator reversed() { 19 | return null; 20 | } 21 | 22 | @Override 23 | public Comparator thenComparing(Comparator other) { 24 | return null; 25 | } 26 | 27 | @Override 28 | public Comparator thenComparing(Function keyExtractor, Comparator keyComparator) { 29 | return null; 30 | } 31 | 32 | @Override 33 | public > Comparator thenComparing(Function keyExtractor) { 34 | return null; 35 | } 36 | 37 | @Override 38 | public Comparator thenComparingInt(ToIntFunction keyExtractor) { 39 | return null; 40 | } 41 | 42 | @Override 43 | public Comparator thenComparingLong(ToLongFunction keyExtractor) { 44 | return null; 45 | } 46 | 47 | @Override 48 | public Comparator thenComparingDouble(ToDoubleFunction keyExtractor) { 49 | return null; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /cukeulator/src/androidTest/java/cucumber/cukeulator/test/SomeDependency.java: -------------------------------------------------------------------------------- 1 | package cucumber.cukeulator.test; 2 | 3 | // Dummy class to demonstrate dependency injection 4 | public class SomeDependency { 5 | } 6 | -------------------------------------------------------------------------------- /cukeulator/src/androidTest/java/cucumber/cukeulator/test/TypeRegistryConfiguration.java: -------------------------------------------------------------------------------- 1 | package cucumber.cukeulator.test; 2 | 3 | public final class TypeRegistryConfiguration { 4 | 5 | private TypeRegistryConfiguration() { 6 | } 7 | 8 | @io.cucumber.java.ParameterType("[0-9]") 9 | public static int digit(String param) { 10 | return Integer.parseInt(param); 11 | } 12 | 13 | @io.cucumber.java.ParameterType("[+–x\\/=]") 14 | public static char operator(String param) { 15 | return param.charAt(0); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /cukeulator/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /cukeulator/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /cukeulator/src/main/java/cucumber/cukeulator/CalculatorActivity.java: -------------------------------------------------------------------------------- 1 | package cucumber.cukeulator; 2 | 3 | import android.os.Bundle; 4 | import android.view.View; 5 | import android.widget.TextView; 6 | 7 | import androidx.activity.ComponentActivity; 8 | 9 | import cucumber.cukeulator.databinding.ActivityCalculatorBinding; 10 | 11 | public class CalculatorActivity extends ComponentActivity { 12 | private enum Operation {ADD, SUB, MULT, DIV, NONE} 13 | private Operation operation; 14 | private boolean decimals; 15 | private boolean resetDisplay; 16 | private boolean performOperation; 17 | private double value; 18 | private ActivityCalculatorBinding binding; 19 | 20 | @Override 21 | public void onCreate(Bundle savedInstanceState) { 22 | super.onCreate(savedInstanceState); 23 | binding = ActivityCalculatorBinding.inflate(getLayoutInflater()); 24 | setContentView(binding.getRoot()); 25 | operation = Operation.NONE; 26 | 27 | binding.btnD0.setOnClickListener(this::onDigitPressed); 28 | binding.btnD1.setOnClickListener(this::onDigitPressed); 29 | binding.btnD2.setOnClickListener(this::onDigitPressed); 30 | binding.btnD3.setOnClickListener(this::onDigitPressed); 31 | binding.btnD4.setOnClickListener(this::onDigitPressed); 32 | binding.btnD5.setOnClickListener(this::onDigitPressed); 33 | binding.btnD6.setOnClickListener(this::onDigitPressed); 34 | binding.btnD7.setOnClickListener(this::onDigitPressed); 35 | binding.btnD8.setOnClickListener(this::onDigitPressed); 36 | binding.btnD9.setOnClickListener(this::onDigitPressed); 37 | 38 | binding.btnOpAdd.setOnClickListener(v -> onOperatorPressed(v, Operation.ADD)); 39 | binding.btnOpSubtract.setOnClickListener(v -> onOperatorPressed(v, Operation.SUB)); 40 | binding.btnOpMultiply.setOnClickListener(v -> onOperatorPressed(v, Operation.MULT)); 41 | binding.btnOpDivide.setOnClickListener(v -> onOperatorPressed(v, Operation.DIV)); 42 | binding.btnOpEquals.setOnClickListener(v -> { 43 | if (performOperation) { 44 | performOperation(); 45 | performOperation = false; 46 | } 47 | resetDisplay = true; 48 | value = getDisplayValue(); 49 | }); 50 | 51 | TextView txtCalcDisplay = binding.txtCalcDisplay; 52 | binding.btnSpecClear.setOnClickListener(v -> onSpecialPressed(() -> { 53 | value = 0; 54 | decimals = false; 55 | operation = Operation.NONE; 56 | txtCalcDisplay.setText(null); 57 | binding.txtCalcOperator.setText(null); 58 | })); 59 | 60 | binding.btnSpecComma.setOnClickListener(v -> onSpecialPressed(() -> { 61 | if (!decimals) { 62 | String text = displayIsEmpty() ? "0." : "."; 63 | txtCalcDisplay.append(text); 64 | decimals = true; 65 | } 66 | })); 67 | binding.btnSpecPercent.setOnClickListener(v -> onSpecialPressed(() -> { 68 | double value = getDisplayValue(); 69 | double percent = value / 100.0F; 70 | txtCalcDisplay.setText(Double.toString(percent)); 71 | })); 72 | binding.btnSpecSqroot.setOnClickListener(v -> onSpecialPressed(() -> { 73 | double value = getDisplayValue(); 74 | double sqrt = Math.sqrt(value); 75 | txtCalcDisplay.setText(Double.toString(sqrt)); 76 | })); 77 | binding.btnSpecPi.setOnClickListener(v -> onSpecialPressed(() -> { 78 | resetDisplay = false; 79 | binding.txtCalcOperator.setText(null); 80 | txtCalcDisplay.setText(Double.toString(Math.PI)); 81 | if (operation != Operation.NONE) performOperation = true; 82 | })); 83 | } 84 | 85 | public void onDigitPressed(View v) { 86 | if (resetDisplay) { 87 | binding.txtCalcDisplay.setText(null); 88 | resetDisplay = false; 89 | } 90 | binding.txtCalcOperator.setText(null); 91 | 92 | if (decimals || !only0IsDisplayed()) binding.txtCalcDisplay.append(((TextView) v).getText()); 93 | 94 | if (operation != Operation.NONE) performOperation = true; 95 | } 96 | 97 | public void onOperatorPressed(View v, Operation operation) { 98 | if (performOperation) { 99 | performOperation(); 100 | performOperation = false; 101 | } 102 | this.operation = operation; 103 | binding.txtCalcOperator.setText(((TextView) v).getText()); 104 | resetDisplay = true; 105 | value = getDisplayValue(); 106 | } 107 | 108 | public void onSpecialPressed(Runnable action) { 109 | action.run(); 110 | resetDisplay = false; 111 | performOperation = false; 112 | } 113 | 114 | private void performOperation() { 115 | double display = getDisplayValue(); 116 | 117 | switch (operation) { 118 | case DIV: 119 | value = value / display; 120 | break; 121 | case MULT: 122 | value = value * display; 123 | break; 124 | case SUB: 125 | value = value - display; 126 | break; 127 | case ADD: 128 | value = value + display; 129 | break; 130 | case NONE: 131 | return; 132 | default: 133 | throw new RuntimeException("Unsupported operation."); 134 | } 135 | binding.txtCalcOperator.setText(null); 136 | binding.txtCalcDisplay.setText(Double.toString(value)); 137 | } 138 | 139 | private boolean only0IsDisplayed() { 140 | CharSequence text = binding.txtCalcDisplay.getText(); 141 | return text.length() == 1 && text.charAt(0) == '0'; 142 | } 143 | 144 | private boolean displayIsEmpty() { 145 | return binding.txtCalcDisplay.getText().length() == 0; 146 | } 147 | 148 | private double getDisplayValue() { 149 | String display = binding.txtCalcDisplay.getText().toString(); 150 | return display.isEmpty() ? 0.0F : Double.parseDouble(display); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /cukeulator/src/main/java/cucumber/cukeulator/ComposeTestActivity.kt: -------------------------------------------------------------------------------- 1 | package cucumber.cukeulator 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.os.Bundle 6 | import androidx.activity.compose.setContent 7 | import androidx.appcompat.app.AppCompatActivity 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.foundation.layout.wrapContentWidth 10 | import androidx.compose.material.MaterialTheme 11 | import androidx.compose.material.Text 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.ui.Alignment 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.res.dimensionResource 16 | import dagger.hilt.android.AndroidEntryPoint 17 | import javax.inject.Inject 18 | 19 | @AndroidEntryPoint 20 | class ComposeTestActivity:AppCompatActivity() { 21 | 22 | companion object { 23 | 24 | private val key = "text" 25 | 26 | fun create(context: Context, text:String? = null):Intent = Intent(context,ComposeTestActivity::class.java).putExtra( 27 | key,text) 28 | } 29 | 30 | @Inject 31 | lateinit var greetingService: GreetingService 32 | 33 | override fun onCreate(savedInstanceState: Bundle?) { 34 | super.onCreate(savedInstanceState) 35 | setContent { 36 | MaterialTheme { 37 | Greeting() 38 | } 39 | 40 | } 41 | } 42 | 43 | @Composable 44 | private fun Greeting() { 45 | Text( 46 | text = intent.getStringExtra(key)?:greetingService.greeting(this), 47 | modifier = Modifier 48 | .padding(horizontal = dimensionResource(R.dimen.activity_horizontal_margin)) 49 | .wrapContentWidth(Alignment.CenterHorizontally) 50 | ) 51 | } 52 | 53 | } -------------------------------------------------------------------------------- /cukeulator/src/main/java/cucumber/cukeulator/CukeulatorApplication.kt: -------------------------------------------------------------------------------- 1 | package cucumber.cukeulator 2 | 3 | import android.app.Application 4 | import dagger.hilt.android.HiltAndroidApp 5 | 6 | 7 | @HiltAndroidApp 8 | class CukeulatorApplication:Application() { 9 | } -------------------------------------------------------------------------------- /cukeulator/src/main/java/cucumber/cukeulator/GreetingService.kt: -------------------------------------------------------------------------------- 1 | package cucumber.cukeulator 2 | 3 | import android.content.Context 4 | 5 | fun interface GreetingService { 6 | 7 | fun greeting(context: Context):String 8 | } -------------------------------------------------------------------------------- /cukeulator/src/main/java/cucumber/cukeulator/HiltModule.kt: -------------------------------------------------------------------------------- 1 | package cucumber.cukeulator 2 | 3 | import dagger.Module 4 | import dagger.Provides 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.android.components.ActivityComponent 7 | import dagger.hilt.components.SingletonComponent 8 | import javax.inject.Singleton 9 | 10 | @Module 11 | @InstallIn(SingletonComponent::class) 12 | class HiltModule { 13 | 14 | @Provides 15 | @Singleton 16 | fun service():GreetingService = GreetingService { it.getString(R.string.hello_world) } 17 | } 18 | 19 | -------------------------------------------------------------------------------- /cukeulator/src/main/res/drawable-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cucumber/cucumber-android/0c08b28eefa4eb23fb60fb674b36e956931669bf/cukeulator/src/main/res/drawable-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /cukeulator/src/main/res/drawable-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cucumber/cucumber-android/0c08b28eefa4eb23fb60fb674b36e956931669bf/cukeulator/src/main/res/drawable-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /cukeulator/src/main/res/drawable-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cucumber/cucumber-android/0c08b28eefa4eb23fb60fb674b36e956931669bf/cukeulator/src/main/res/drawable-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /cukeulator/src/main/res/drawable-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cucumber/cucumber-android/0c08b28eefa4eb23fb60fb674b36e956931669bf/cukeulator/src/main/res/drawable-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /cukeulator/src/main/res/layout/activity_calculator.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 17 | 18 | 31 | 32 | 41 | 42 | 43 |