├── .gitignore ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── nl │ │ └── jovmit │ │ ├── extensions.kt │ │ ├── login │ │ ├── LoginActivityTest.kt │ │ └── LoginTestRobots.kt │ │ └── main │ │ └── MainActivityTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── nl │ │ │ └── jovmit │ │ │ ├── login │ │ │ ├── LoginActivity.kt │ │ │ ├── LoginDataSource.kt │ │ │ └── data │ │ │ │ └── LoginResponse.kt │ │ │ └── main │ │ │ └── MainActivity.kt │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ └── ic_launcher_background.xml │ │ ├── layout │ │ ├── activity_login.xml │ │ └── activity_main.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ └── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── styles.xml │ ├── mock │ └── java │ │ └── nl │ │ └── jovmit │ │ └── login │ │ └── RemoteLoginDataSource.kt │ ├── prod │ └── java │ │ └── nl │ │ └── jovmit │ │ └── login │ │ └── RemoteLoginDataSource.kt │ └── test │ └── java │ └── nl │ └── jovmit │ └── login │ └── RemoteLoginTest.kt ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | # 2 | # ANDROID 3 | # 4 | # built application files 5 | *.apk 6 | 7 | # files for the dex VM 8 | *.dex 9 | 10 | # Java class files 11 | *.class 12 | 13 | # generated files 14 | bin/ 15 | gen/ 16 | *.so 17 | 18 | # Ignore gradle files 19 | .gradle/ 20 | build/ 21 | .externalNativeBuild/ 22 | 23 | # Local configuration file (sdk path, etc) 24 | local.properties 25 | releasePassword 26 | 27 | # Crashlytics data 28 | .crashlytics_data 29 | 30 | # External tool builders 31 | .externalToolBuilders/ 32 | 33 | # 34 | # IDEA 35 | # 36 | .idea/ 37 | *.iml 38 | *.iws 39 | 40 | # 41 | # VIM 42 | # 43 | [._]*.s[a-w][a-z] 44 | [._]s[a-w][a-z] 45 | *.un~ 46 | Session.vim 47 | .netrwhist 48 | *~ 49 | 50 | # 51 | # MAC 52 | # 53 | .DS_Store 54 | 55 | # 56 | # SONAR 57 | # 58 | .sonar 59 | crashlytics-build.properties 60 | 61 | 62 | #output 63 | mocko/output.txt 64 | 65 | 66 | #memory allocation tracker 67 | captures/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Jovche Mitrejchevski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Login Demo 2 | 3 | ## Highlight 4 | The purpose of the project is to demonstrate the approach of testing the UI in Android app, 5 | by using espresso. The project has a very simple login screen, and based on the input it has to show 6 | an error, or open the main application screen. 7 | 8 | ## Details 9 | The key point of this project is to demonstrate the approach for getting a rock-solid UI tests that would 10 | run on any environment, without any external dependencies. Traditionally, there were different approaches 11 | for mocking a rest service that would run on the same machine with the emulator, so the app would make 12 | the real calls. In this example, the test doubles are created and kept in the source code, so the app would not 13 | need any external dependencies for running the UI tests. Furthermore, the replies are very fast so there 14 | is no need for any idling resources. The idea is to focus on the UI, because the initial intention is to test the UI, 15 | not the actual calls.
16 | At the beginning, it's very important to note that the project has 2 flavors: **mock** and **prod**. 17 | The reason behind is to separate the data source. The production data source implementation would make the real 18 | work (calls to sever, etc), while the mock implementation would return mocked replies (aka test-doubles) based on the input. 19 | This could have been done in many different ways (using dagger for instance), but the intention here is to 20 | achieve the goal with as little dependencies as possible, so it will be very simple to be understood. 21 | 22 | The project consists of 2 branches: 23 | 24 | ### master 25 | A very naive implementation of the login. The reason behind is to make the example as simple as possible 26 | so the intention of the approach will be very clear and precise. Once again, the key point is to make the 27 | UI tests simple and solid. 28 | 29 | ### full 30 | This branch has the implementation very similar to the one in [master](#master). The difference is that it 31 | also uses the architecture components (ViewModel and LiveData in particular), so the whole picture would be 32 | nicer, and a little bit more real. 33 | 34 | ## Having Different/Better Ideas 35 | If you know a better or nicer or simpler way for doing this, feel free to open a PR, or at least an issue 36 | so we could discuss, share knowledge and learn. -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-android-extensions' 4 | 5 | android { 6 | compileSdkVersion 27 7 | defaultConfig { 8 | applicationId "nl.jovmit" 9 | minSdkVersion 19 10 | targetSdkVersion 27 11 | versionCode 1 12 | versionName "1.0" 13 | testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" 14 | } 15 | 16 | buildTypes { 17 | release { 18 | minifyEnabled true 19 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 20 | } 21 | } 22 | 23 | productFlavors { 24 | mock { 25 | flavorDimensions "default" 26 | } 27 | prod { 28 | flavorDimensions "default" 29 | } 30 | } 31 | 32 | android.variantFilter { variant -> 33 | if (variant.buildType.name == 'release' && variant.getFlavors().get(0).name == 'mock') { 34 | variant.setIgnore(true) 35 | } 36 | } 37 | } 38 | 39 | dependencies { 40 | implementation fileTree(dir: 'libs', include: ['*.jar']) 41 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version" 42 | implementation "com.android.support:appcompat-v7:$support_version" 43 | implementation "com.android.support:design:$support_version" 44 | implementation 'com.android.support.constraint:constraint-layout:1.0.2' 45 | 46 | androidTestImplementation 'com.android.support.test:runner:1.0.1' 47 | androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.1' 48 | 49 | testImplementation 'junit:junit:4.12' 50 | } 51 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /app/src/androidTest/java/nl/jovmit/extensions.kt: -------------------------------------------------------------------------------- 1 | package nl.jovmit 2 | 3 | import android.support.annotation.StringRes 4 | import android.support.test.espresso.Espresso.onView 5 | import android.support.test.espresso.ViewAction 6 | import android.support.test.espresso.ViewAssertion 7 | import android.support.test.espresso.ViewInteraction 8 | import android.support.test.espresso.assertion.ViewAssertions.matches 9 | import android.support.test.espresso.matcher.ViewMatchers 10 | import android.support.test.espresso.matcher.ViewMatchers.isAssignableFrom 11 | import android.support.test.espresso.matcher.ViewMatchers.withHint 12 | import android.support.test.espresso.matcher.ViewMatchers.withId 13 | import android.support.test.espresso.matcher.ViewMatchers.withParent 14 | import android.support.test.espresso.matcher.ViewMatchers.withText 15 | import android.support.v7.widget.Toolbar 16 | import org.hamcrest.Matchers.allOf 17 | 18 | val isDisplayed: ViewAssertion = matches(ViewMatchers.isDisplayed()) 19 | 20 | fun toolbarWithTitle(@StringRes title: Int): ViewInteraction = 21 | onView(allOf(withText(title), withParent(isAssignableFrom(Toolbar::class.java)))) 22 | 23 | fun text(@StringRes resource: Int): ViewInteraction = onView(withText(resource)) 24 | 25 | infix fun ViewInteraction.check(action: ViewAssertion): ViewInteraction = this.check(action) 26 | 27 | infix fun ViewInteraction.hasHint(@StringRes string: Int): ViewInteraction = this check matches(withHint(string)) 28 | 29 | infix fun ViewInteraction.hasText(@StringRes string: Int): ViewInteraction = this check matches(withText(string)) 30 | 31 | infix fun Int.perform(action: ViewAction): ViewInteraction = onView(withId(this)).perform(action) 32 | 33 | infix fun Int.check(action: ViewAssertion): ViewInteraction = onView(withId(this)).check(action) 34 | 35 | infix fun Int.hasHint(@StringRes resource: Int): ViewInteraction = onView(withId(this)) hasHint resource 36 | 37 | infix fun Int.hasText(@StringRes resource: Int): ViewInteraction = onView(withId(this)) hasText resource -------------------------------------------------------------------------------- /app/src/androidTest/java/nl/jovmit/login/LoginActivityTest.kt: -------------------------------------------------------------------------------- 1 | package nl.jovmit.login 2 | 3 | import android.support.test.rule.ActivityTestRule 4 | import android.support.test.runner.AndroidJUnit4 5 | import nl.jovmit.login.LoginTestRobot.Companion.loginScreen 6 | import org.junit.Rule 7 | import org.junit.Test 8 | import org.junit.runner.RunWith 9 | 10 | @RunWith(AndroidJUnit4::class) 11 | class LoginActivityTest { 12 | 13 | @Rule 14 | @JvmField 15 | val rule = ActivityTestRule(LoginActivity::class.java) 16 | 17 | @Test 18 | fun shouldApplyCorrectTitle() { 19 | loginScreen { 20 | hasLoginTitle() 21 | } 22 | } 23 | 24 | @Test 25 | fun shouldContainUsernameInput() { 26 | loginScreen { 27 | includesUsername() 28 | } 29 | } 30 | 31 | @Test 32 | fun usernameInputShouldApplyCorrectHint() { 33 | loginScreen { 34 | usernameEditor { 35 | hasCorrectHint() 36 | } 37 | } 38 | } 39 | 40 | @Test 41 | fun shouldContainPasswordInput() { 42 | loginScreen { 43 | includesPassword() 44 | } 45 | } 46 | 47 | @Test 48 | fun passwordInputShouldApplyCorrectHint() { 49 | loginScreen { 50 | passwordEditor { 51 | hasCorrectHint() 52 | } 53 | } 54 | } 55 | 56 | @Test 57 | fun shouldContainLoginButton() { 58 | loginScreen { 59 | includesLoginButton() 60 | } 61 | } 62 | 63 | @Test 64 | fun loginButtonShouldApplyCorrectText() { 65 | loginScreen { 66 | loginButton { 67 | hasCorrectTitle() 68 | } 69 | } 70 | } 71 | 72 | @Test 73 | fun loginWithoutUsernameShouldDisplayError() { 74 | loginScreen { 75 | typeUsername("") 76 | typePassword("") 77 | } submit { 78 | displaysEmptyUsernameError() 79 | } 80 | } 81 | 82 | @Test 83 | fun loginWithEmptyPasswordShouldDisplayError() { 84 | loginScreen { 85 | typeUsername("username") 86 | typePassword("") 87 | } submit { 88 | displaysEmptyPasswordError() 89 | } 90 | } 91 | 92 | @Test 93 | fun loginWithIncorrectCredentialsShouldDisplayError() { 94 | loginScreen { 95 | typeUsername("username") 96 | typePassword("password") 97 | } submit { 98 | displaysInvalidAuthError() 99 | } 100 | } 101 | 102 | @Test 103 | fun successfulLoginShouldOpenMainScreen() { 104 | loginScreen { 105 | typeUsername("user") 106 | typePassword("pass") 107 | } submit { 108 | opensMainScreen() 109 | } 110 | } 111 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/nl/jovmit/login/LoginTestRobots.kt: -------------------------------------------------------------------------------- 1 | package nl.jovmit.login 2 | 3 | import android.support.test.espresso.action.ViewActions 4 | import android.support.test.espresso.action.ViewActions.typeText 5 | import nl.jovmit.* 6 | 7 | @DslMarker 8 | private annotation class TestRobotMarker 9 | 10 | @TestRobotMarker 11 | private interface LoginRobot 12 | 13 | class LoginTestRobot : LoginRobot { 14 | 15 | companion object { 16 | 17 | fun loginScreen(block: LoginTestRobot.() -> Unit): LoginTestRobot { 18 | return LoginTestRobot().apply(block) 19 | } 20 | } 21 | 22 | fun hasLoginTitle() = toolbarWithTitle(R.string.login) check isDisplayed 23 | 24 | fun includesUsername() = R.id.loginUsername check isDisplayed 25 | 26 | fun includesPassword() = R.id.loginPassword check isDisplayed 27 | 28 | fun includesLoginButton() = R.id.loginButton check isDisplayed 29 | 30 | fun typeUsername(username: String) = R.id.loginUsername perform typeText(username) 31 | 32 | fun typePassword(password: String) = R.id.loginPassword perform typeText(password) 33 | 34 | fun usernameEditor(block: UsernameRobot.() -> Unit): UsernameRobot { 35 | return UsernameRobot().apply(block) 36 | } 37 | 38 | fun passwordEditor(block: PasswordRobot.() -> Unit): PasswordRobot { 39 | return PasswordRobot().apply(block) 40 | } 41 | 42 | fun loginButton(block: LoginButtonRobot.() -> Unit): LoginButtonRobot { 43 | return LoginButtonRobot().apply(block) 44 | } 45 | 46 | infix fun submit(block: LoginResult.() -> Unit): LoginResult { 47 | R.id.loginButton perform ViewActions.click() 48 | return LoginResult().apply(block) 49 | } 50 | } 51 | 52 | class UsernameRobot : LoginRobot { 53 | 54 | fun hasCorrectHint() = R.id.loginUsername hasHint R.string.username 55 | } 56 | 57 | class PasswordRobot : LoginRobot { 58 | 59 | fun hasCorrectHint() = R.id.loginPassword hasHint R.string.password 60 | } 61 | 62 | class LoginButtonRobot : LoginRobot { 63 | 64 | fun hasCorrectTitle() = R.id.loginButton hasText R.string.login 65 | } 66 | 67 | class LoginResult : LoginRobot { 68 | 69 | fun displaysEmptyUsernameError() = text(R.string.errorEmptyUsername) check isDisplayed 70 | 71 | fun displaysEmptyPasswordError() = text(R.string.errorEmptyPassword) check isDisplayed 72 | 73 | fun displaysInvalidAuthError() = text(R.string.errorInvalidLogin) check isDisplayed 74 | 75 | fun opensMainScreen() = R.id.mainGreetingMessage check isDisplayed 76 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/nl/jovmit/main/MainActivityTest.kt: -------------------------------------------------------------------------------- 1 | package nl.jovmit.main 2 | 3 | import android.support.test.rule.ActivityTestRule 4 | import android.support.test.runner.AndroidJUnit4 5 | import nl.jovmit.* 6 | import org.junit.Rule 7 | import org.junit.Test 8 | import org.junit.runner.RunWith 9 | 10 | @RunWith(AndroidJUnit4::class) 11 | class MainActivityTest { 12 | 13 | @Rule 14 | @JvmField 15 | val rule = ActivityTestRule(MainActivity::class.java) 16 | 17 | @Test 18 | fun screenShouldApplyCorrectTitle() { 19 | toolbarWithTitle(R.string.hello) check isDisplayed 20 | } 21 | 22 | @Test 23 | fun screenShouldContainGreetingMessage() { 24 | R.id.mainGreetingMessage check isDisplayed 25 | } 26 | 27 | @Test 28 | fun greetingMessageShouldApplyCorrectText() { 29 | R.id.mainGreetingMessage hasText R.string.hello 30 | } 31 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /app/src/main/java/nl/jovmit/login/LoginActivity.kt: -------------------------------------------------------------------------------- 1 | package nl.jovmit.login 2 | 3 | import android.content.Intent 4 | import android.os.Bundle 5 | import android.support.design.widget.Snackbar 6 | import android.support.v7.app.AppCompatActivity 7 | import kotlinx.android.synthetic.main.activity_login.* 8 | import nl.jovmit.R 9 | import nl.jovmit.login.data.LoginResponse 10 | import nl.jovmit.main.MainActivity 11 | import kotlin.LazyThreadSafetyMode.NONE 12 | 13 | class LoginActivity : AppCompatActivity() { 14 | 15 | private val loginDataSource by lazy(NONE) { RemoteLoginDataSource() } 16 | 17 | override fun onCreate(savedInstanceState: Bundle?) { 18 | super.onCreate(savedInstanceState) 19 | setContentView(R.layout.activity_login) 20 | setupLayout() 21 | } 22 | 23 | private fun setupLayout() { 24 | supportActionBar?.setTitle(R.string.login) 25 | loginButton.setOnClickListener { performLogin() } 26 | } 27 | 28 | private fun performLogin() { 29 | val username = loginUsername.text.toString() 30 | val password = loginPassword.text.toString() 31 | val result = loginDataSource.login(username, password) 32 | when (result) { 33 | is LoginResponse.Error -> displayError(result) 34 | is LoginResponse.Success -> openMainScreen() 35 | } 36 | } 37 | 38 | private fun displayError(result: LoginResponse.Error) { 39 | Snackbar.make(loginLayoutRoot, result.resource, Snackbar.LENGTH_SHORT).show() 40 | } 41 | 42 | private fun openMainScreen() { 43 | startActivity(Intent(this, MainActivity::class.java)) 44 | finish() 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/src/main/java/nl/jovmit/login/LoginDataSource.kt: -------------------------------------------------------------------------------- 1 | package nl.jovmit.login 2 | 3 | import nl.jovmit.login.data.LoginResponse 4 | 5 | interface LoginDataSource { 6 | 7 | fun login(username: String, password: String): LoginResponse 8 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/jovmit/login/data/LoginResponse.kt: -------------------------------------------------------------------------------- 1 | package nl.jovmit.login.data 2 | 3 | import android.support.annotation.StringRes 4 | 5 | sealed class LoginResponse { 6 | 7 | data class Success(val accessToken: String) : LoginResponse() 8 | 9 | data class Error(@StringRes val resource: Int) : LoginResponse() 10 | } -------------------------------------------------------------------------------- /app/src/main/java/nl/jovmit/main/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package nl.jovmit.main 2 | 3 | import android.os.Bundle 4 | import android.support.v7.app.AppCompatActivity 5 | import nl.jovmit.R 6 | 7 | class MainActivity : AppCompatActivity() { 8 | 9 | override fun onCreate(savedInstanceState: Bundle?) { 10 | super.onCreate(savedInstanceState) 11 | setContentView(R.layout.activity_main) 12 | supportActionBar?.setTitle(R.string.hello) 13 | } 14 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 19 | 22 | 25 | 26 | 27 | 28 | 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_login.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 16 | 17 | 23 | 24 | 35 | 36 | 47 | 48 |