From 7453e20815003c9a8e30b82a4db8ead3f4d0dc99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Sastre=20Fl=C3=B3rez?= Date: Sun, 28 Nov 2021 06:01:44 +0100 Subject: [PATCH] Jqwik failing due to AnnotatedType --- app/build.gradle | 41 +++++- .../unittests/ui/login/LoginActivityTest2.kt | 82 +++++++++++ app/src/main/AndroidManifest.xml | 12 +- .../of/unittests/data/LoginDataSource.kt | 24 ++++ .../of/unittests/data/LoginRepository.kt | 46 +++++++ .../quality/of/unittests/data/Result.kt | 18 +++ .../of/unittests/data/model/LoggedInUser.kt | 9 ++ .../of/unittests/ui/login/LoggedInUserView.kt | 9 ++ .../of/unittests/ui/login/LoginActivity.kt | 130 ++++++++++++++++++ .../of/unittests/ui/login/LoginFormState.kt | 10 ++ .../of/unittests/ui/login/LoginResult.kt | 9 ++ .../of/unittests/ui/login/LoginViewModel.kt | 55 ++++++++ .../ui/login/LoginViewModelFactory.kt | 25 ++++ .../res/layout-w1240dp/activity_login.xml | 69 ++++++++++ .../main/res/layout-w936dp/activity_login.xml | 76 ++++++++++ app/src/main/res/layout/activity_login.xml | 69 ++++++++++ app/src/main/res/values-land/dimens.xml | 3 + app/src/main/res/values-w1240dp/dimens.xml | 3 + app/src/main/res/values-w600dp/dimens.xml | 3 + app/src/main/res/values/dimens.xml | 5 + app/src/main/res/values/strings.xml | 9 ++ 21 files changed, 701 insertions(+), 6 deletions(-) create mode 100644 app/src/androidTest/java/sergio/sastre/multiplying/quality/of/unittests/ui/login/LoginActivityTest2.kt create mode 100644 app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/data/LoginDataSource.kt create mode 100644 app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/data/LoginRepository.kt create mode 100644 app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/data/Result.kt create mode 100644 app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/data/model/LoggedInUser.kt create mode 100644 app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/ui/login/LoggedInUserView.kt create mode 100644 app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/ui/login/LoginActivity.kt create mode 100644 app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/ui/login/LoginFormState.kt create mode 100644 app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/ui/login/LoginResult.kt create mode 100644 app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/ui/login/LoginViewModel.kt create mode 100644 app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/ui/login/LoginViewModelFactory.kt create mode 100644 app/src/main/res/layout-w1240dp/activity_login.xml create mode 100644 app/src/main/res/layout-w936dp/activity_login.xml create mode 100644 app/src/main/res/layout/activity_login.xml create mode 100644 app/src/main/res/values-land/dimens.xml create mode 100644 app/src/main/res/values-w1240dp/dimens.xml create mode 100644 app/src/main/res/values-w600dp/dimens.xml create mode 100644 app/src/main/res/values/dimens.xml diff --git a/app/build.gradle b/app/build.gradle index e01464d..1799571 100755 --- a/app/build.gradle +++ b/app/build.gradle @@ -4,17 +4,18 @@ plugins { } android { - compileSdkVersion 29 + compileSdkVersion 31 buildToolsVersion "29.0.3" defaultConfig { applicationId "sergio.sastre.multiplying.quality.of.unittests" minSdkVersion 21 - targetSdkVersion 29 + targetSdkVersion 31 versionCode 1 versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } buildTypes { @@ -47,6 +48,13 @@ android { include '**/*Examples.class' } } + buildFeatures { + viewBinding true + } + + packagingOptions { + exclude "META-INF/*" + } } dependencies { @@ -55,13 +63,36 @@ dependencies { implementation 'androidx.core:core-ktx:1.3.2' implementation 'androidx.appcompat:appcompat:1.2.0' implementation 'com.google.android.material:material:1.3.0' + implementation 'androidx.annotation:annotation:1.2.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.2' + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.0' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0' testImplementation 'junit:junit:4.13.2' - testImplementation "org.junit.jupiter:junit-jupiter-api:5.7.1" - testImplementation "org.junit.jupiter:junit-jupiter-params:5.7.1" - testRuntimeOnly "org.junit.vintage:junit-vintage-engine:5.7.1" + testImplementation "org.junit.jupiter:junit-jupiter-api:5.8.1" + testImplementation "org.junit.jupiter:junit-jupiter-params:5.8.1" + testRuntimeOnly "org.junit.vintage:junit-vintage-engine:5.8.1" testImplementation "com.google.truth:truth:1.1.2" testImplementation("net.jqwik:jqwik:1.6.0") testImplementation("net.jqwik:jqwik-kotlin:1.6.0") + + // Core library + androidTestImplementation 'androidx.test:core:1.4.0' + + // AndroidJUnitRunner and JUnit Rules + androidTestImplementation 'androidx.test:runner:1.4.0' + androidTestImplementation 'androidx.test:rules:1.4.0' + + // Espresso dependencies + androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' + + // Jqwik for instrumented tests + androidTestImplementation("net.jqwik:jqwik:1.6.0") + androidTestImplementation("net.jqwik:jqwik-kotlin:1.6.0") + + // Jqwik requires Junit5 + androidTestImplementation 'junit:junit:4.13.2' + androidTestImplementation("org.junit.platform:junit-platform-runner:1.8.1") + } \ No newline at end of file diff --git a/app/src/androidTest/java/sergio/sastre/multiplying/quality/of/unittests/ui/login/LoginActivityTest2.kt b/app/src/androidTest/java/sergio/sastre/multiplying/quality/of/unittests/ui/login/LoginActivityTest2.kt new file mode 100644 index 0000000..d677237 --- /dev/null +++ b/app/src/androidTest/java/sergio/sastre/multiplying/quality/of/unittests/ui/login/LoginActivityTest2.kt @@ -0,0 +1,82 @@ +package sergio.sastre.multiplying.quality.of.unittests.ui.login + +import android.view.View +import android.view.ViewGroup +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.* +import androidx.test.espresso.matcher.ViewMatchers.* +import net.jqwik.api.* +import org.hamcrest.Description +import org.hamcrest.Matcher +import org.hamcrest.Matchers.allOf +import org.hamcrest.TypeSafeMatcher +import org.junit.runner.RunWith +import org.junit.platform.runner.JUnitPlatform +import sergio.sastre.multiplying.quality.of.unittests.R + +@RunWith(JUnitPlatform::class) +class LoginActivityTest2 { + + @Provide + fun noUpperCase(): Arbitrary = + Arbitraries.strings().ascii().filter { it.matches("[^A-Z]".toRegex()) } + + @Property(tries = 1) + fun loginActivityTest(@ForAll("noUpperCase") password: String?) { + + val appCompatEditText = onView( + allOf( + withId(R.id.username), + childAtPosition( + allOf( + withId(R.id.container), + childAtPosition( + withId(android.R.id.content), + 0 + ) + ), + 0 + ), + isDisplayed() + ) + ) + appCompatEditText.perform(replaceText(password), closeSoftKeyboard()) + + val appCompatEditText2 = onView( + allOf( + withId(R.id.username), withText("hello"), + childAtPosition( + allOf( + withId(R.id.container), + childAtPosition( + withId(android.R.id.content), + 0 + ) + ), + 0 + ), + isDisplayed() + ) + ) + appCompatEditText2.perform(click()) + } + + + private fun childAtPosition( + parentMatcher: Matcher, position: Int + ): Matcher { + + return object : TypeSafeMatcher() { + override fun describeTo(description: Description) { + description.appendText("Child at position $position in parent ") + parentMatcher.describeTo(description) + } + + public override fun matchesSafely(view: View): Boolean { + val parent = view.parent + return parent is ViewGroup && parentMatcher.matches(parent) + && view == parent.getChildAt(position) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ed69088..f4449dd 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,16 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" - android:theme="@style/Theme.MultiplyingTheQualityOfUnitTests" /> + android:theme="@style/Theme.MultiplyingTheQualityOfUnitTests"> + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/data/LoginDataSource.kt b/app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/data/LoginDataSource.kt new file mode 100644 index 0000000..47bf66f --- /dev/null +++ b/app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/data/LoginDataSource.kt @@ -0,0 +1,24 @@ +package sergio.sastre.multiplying.quality.of.unittests.data + +import sergio.sastre.multiplying.quality.of.unittests.data.model.LoggedInUser +import java.io.IOException + +/** + * Class that handles authentication w/ login credentials and retrieves user information. + */ +class LoginDataSource { + + fun login(username: String, password: String): Result { + try { + // TODO: handle loggedInUser authentication + val fakeUser = LoggedInUser(java.util.UUID.randomUUID().toString(), "Jane Doe") + return Result.Success(fakeUser) + } catch (e: Throwable) { + return Result.Error(IOException("Error logging in", e)) + } + } + + fun logout() { + // TODO: revoke authentication + } +} \ No newline at end of file diff --git a/app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/data/LoginRepository.kt b/app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/data/LoginRepository.kt new file mode 100644 index 0000000..6782782 --- /dev/null +++ b/app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/data/LoginRepository.kt @@ -0,0 +1,46 @@ +package sergio.sastre.multiplying.quality.of.unittests.data + +import sergio.sastre.multiplying.quality.of.unittests.data.model.LoggedInUser + +/** + * Class that requests authentication and user information from the remote data source and + * maintains an in-memory cache of login status and user credentials information. + */ + +class LoginRepository(val dataSource: LoginDataSource) { + + // in-memory cache of the loggedInUser object + var user: LoggedInUser? = null + private set + + val isLoggedIn: Boolean + get() = user != null + + init { + // If user credentials will be cached in local storage, it is recommended it be encrypted + // @see https://developer.android.com/training/articles/keystore + user = null + } + + fun logout() { + user = null + dataSource.logout() + } + + fun login(username: String, password: String): Result { + // handle login + val result = dataSource.login(username, password) + + if (result is Result.Success) { + setLoggedInUser(result.data) + } + + return result + } + + private fun setLoggedInUser(loggedInUser: LoggedInUser) { + this.user = loggedInUser + // If user credentials will be cached in local storage, it is recommended it be encrypted + // @see https://developer.android.com/training/articles/keystore + } +} \ No newline at end of file diff --git a/app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/data/Result.kt b/app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/data/Result.kt new file mode 100644 index 0000000..68a42c0 --- /dev/null +++ b/app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/data/Result.kt @@ -0,0 +1,18 @@ +package sergio.sastre.multiplying.quality.of.unittests.data + +/** + * A generic class that holds a value with its loading status. + * @param + */ +sealed class Result { + + data class Success(val data: T) : Result() + data class Error(val exception: Exception) : Result() + + override fun toString(): String { + return when (this) { + is Success<*> -> "Success[data=$data]" + is Error -> "Error[exception=$exception]" + } + } +} \ No newline at end of file diff --git a/app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/data/model/LoggedInUser.kt b/app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/data/model/LoggedInUser.kt new file mode 100644 index 0000000..819c03c --- /dev/null +++ b/app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/data/model/LoggedInUser.kt @@ -0,0 +1,9 @@ +package sergio.sastre.multiplying.quality.of.unittests.data.model + +/** + * Data class that captures user information for logged in users retrieved from LoginRepository + */ +data class LoggedInUser( + val userId: String, + val displayName: String +) \ No newline at end of file diff --git a/app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/ui/login/LoggedInUserView.kt b/app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/ui/login/LoggedInUserView.kt new file mode 100644 index 0000000..3c4b58a --- /dev/null +++ b/app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/ui/login/LoggedInUserView.kt @@ -0,0 +1,9 @@ +package sergio.sastre.multiplying.quality.of.unittests.ui.login + +/** + * User details post authentication that is exposed to the UI + */ +data class LoggedInUserView( + val displayName: String + //... other data fields that may be accessible to the UI +) \ No newline at end of file diff --git a/app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/ui/login/LoginActivity.kt b/app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/ui/login/LoginActivity.kt new file mode 100644 index 0000000..a27860b --- /dev/null +++ b/app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/ui/login/LoginActivity.kt @@ -0,0 +1,130 @@ +package sergio.sastre.multiplying.quality.of.unittests.ui.login + +import android.app.Activity +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProvider +import android.os.Bundle +import androidx.annotation.StringRes +import androidx.appcompat.app.AppCompatActivity +import android.text.Editable +import android.text.TextWatcher +import android.view.View +import android.view.inputmethod.EditorInfo +import android.widget.EditText +import android.widget.Toast +import sergio.sastre.multiplying.quality.of.unittests.databinding.ActivityLoginBinding + +import sergio.sastre.multiplying.quality.of.unittests.R + +class LoginActivity : AppCompatActivity() { + + private lateinit var loginViewModel: LoginViewModel + private lateinit var binding: ActivityLoginBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binding = ActivityLoginBinding.inflate(layoutInflater) + setContentView(binding.root) + + val username = binding.username + val password = binding.password + val login = binding.login + val loading = binding.loading + + loginViewModel = ViewModelProvider(this, LoginViewModelFactory()) + .get(LoginViewModel::class.java) + + loginViewModel.loginFormState.observe(this@LoginActivity, Observer { + val loginState = it ?: return@Observer + + // disable login button unless both username / password is valid + login.isEnabled = loginState.isDataValid + + if (loginState.usernameError != null) { + username.error = getString(loginState.usernameError) + } + if (loginState.passwordError != null) { + password.error = getString(loginState.passwordError) + } + }) + + loginViewModel.loginResult.observe(this@LoginActivity, Observer { + val loginResult = it ?: return@Observer + + loading.visibility = View.GONE + if (loginResult.error != null) { + showLoginFailed(loginResult.error) + } + if (loginResult.success != null) { + updateUiWithUser(loginResult.success) + } + setResult(Activity.RESULT_OK) + + //Complete and destroy login activity once successful + finish() + }) + + username.afterTextChanged { + loginViewModel.loginDataChanged( + username.text.toString(), + password.text.toString() + ) + } + + password.apply { + afterTextChanged { + loginViewModel.loginDataChanged( + username.text.toString(), + password.text.toString() + ) + } + + setOnEditorActionListener { _, actionId, _ -> + when (actionId) { + EditorInfo.IME_ACTION_DONE -> + loginViewModel.login( + username.text.toString(), + password.text.toString() + ) + } + false + } + + login.setOnClickListener { + loading.visibility = View.VISIBLE + loginViewModel.login(username.text.toString(), password.text.toString()) + } + } + } + + private fun updateUiWithUser(model: LoggedInUserView) { + val welcome = getString(R.string.welcome) + val displayName = model.displayName + // TODO : initiate successful logged in experience + Toast.makeText( + applicationContext, + "$welcome $displayName", + Toast.LENGTH_LONG + ).show() + } + + private fun showLoginFailed(@StringRes errorString: Int) { + Toast.makeText(applicationContext, errorString, Toast.LENGTH_SHORT).show() + } +} + +/** + * Extension function to simplify setting an afterTextChanged action to EditText components. + */ +fun EditText.afterTextChanged(afterTextChanged: (String) -> Unit) { + this.addTextChangedListener(object : TextWatcher { + override fun afterTextChanged(editable: Editable?) { + afterTextChanged.invoke(editable.toString()) + } + + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {} + + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {} + }) +} \ No newline at end of file diff --git a/app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/ui/login/LoginFormState.kt b/app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/ui/login/LoginFormState.kt new file mode 100644 index 0000000..8698ee0 --- /dev/null +++ b/app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/ui/login/LoginFormState.kt @@ -0,0 +1,10 @@ +package sergio.sastre.multiplying.quality.of.unittests.ui.login + +/** + * Data validation state of the login form. + */ +data class LoginFormState( + val usernameError: Int? = null, + val passwordError: Int? = null, + val isDataValid: Boolean = false +) \ No newline at end of file diff --git a/app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/ui/login/LoginResult.kt b/app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/ui/login/LoginResult.kt new file mode 100644 index 0000000..e44e085 --- /dev/null +++ b/app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/ui/login/LoginResult.kt @@ -0,0 +1,9 @@ +package sergio.sastre.multiplying.quality.of.unittests.ui.login + +/** + * Authentication result : success (user details) or error message. + */ +data class LoginResult( + val success: LoggedInUserView? = null, + val error: Int? = null +) \ No newline at end of file diff --git a/app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/ui/login/LoginViewModel.kt b/app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/ui/login/LoginViewModel.kt new file mode 100644 index 0000000..dfacf09 --- /dev/null +++ b/app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/ui/login/LoginViewModel.kt @@ -0,0 +1,55 @@ +package sergio.sastre.multiplying.quality.of.unittests.ui.login + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import android.util.Patterns +import sergio.sastre.multiplying.quality.of.unittests.data.LoginRepository +import sergio.sastre.multiplying.quality.of.unittests.data.Result + +import sergio.sastre.multiplying.quality.of.unittests.R + +class LoginViewModel(private val loginRepository: LoginRepository) : ViewModel() { + + private val _loginForm = MutableLiveData() + val loginFormState: LiveData = _loginForm + + private val _loginResult = MutableLiveData() + val loginResult: LiveData = _loginResult + + fun login(username: String, password: String) { + // can be launched in a separate asynchronous job + val result = loginRepository.login(username, password) + + if (result is Result.Success) { + _loginResult.value = + LoginResult(success = LoggedInUserView(displayName = result.data.displayName)) + } else { + _loginResult.value = LoginResult(error = R.string.login_failed) + } + } + + fun loginDataChanged(username: String, password: String) { + if (!isUserNameValid(username)) { + _loginForm.value = LoginFormState(usernameError = R.string.invalid_username) + } else if (!isPasswordValid(password)) { + _loginForm.value = LoginFormState(passwordError = R.string.invalid_password) + } else { + _loginForm.value = LoginFormState(isDataValid = true) + } + } + + // A placeholder username validation check + private fun isUserNameValid(username: String): Boolean { + return if (username.contains('@')) { + Patterns.EMAIL_ADDRESS.matcher(username).matches() + } else { + username.isNotBlank() + } + } + + // A placeholder password validation check + private fun isPasswordValid(password: String): Boolean { + return password.length > 5 + } +} \ No newline at end of file diff --git a/app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/ui/login/LoginViewModelFactory.kt b/app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/ui/login/LoginViewModelFactory.kt new file mode 100644 index 0000000..df3265e --- /dev/null +++ b/app/src/main/java/sergio/sastre/multiplying/quality/of/unittests/ui/login/LoginViewModelFactory.kt @@ -0,0 +1,25 @@ +package sergio.sastre.multiplying.quality.of.unittests.ui.login + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import sergio.sastre.multiplying.quality.of.unittests.data.LoginDataSource +import sergio.sastre.multiplying.quality.of.unittests.data.LoginRepository + +/** + * ViewModel provider factory to instantiate LoginViewModel. + * Required given LoginViewModel has a non-empty constructor + */ +class LoginViewModelFactory : ViewModelProvider.Factory { + + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(LoginViewModel::class.java)) { + return LoginViewModel( + loginRepository = LoginRepository( + dataSource = LoginDataSource() + ) + ) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} \ No newline at end of file diff --git a/app/src/main/res/layout-w1240dp/activity_login.xml b/app/src/main/res/layout-w1240dp/activity_login.xml new file mode 100644 index 0000000..2445403 --- /dev/null +++ b/app/src/main/res/layout-w1240dp/activity_login.xml @@ -0,0 +1,69 @@ + + + + + + + +