Benoit Marty
2 years ago
committed by
Benoit Marty
14 changed files with 60 additions and 882 deletions
@ -1,55 +0,0 @@ |
|||||||
<!--- TOC --> |
|
||||||
|
|
||||||
* [Testing database migration](#testing-database-migration) |
|
||||||
* [Creating a reference database](#creating-a-reference-database) |
|
||||||
* [Testing](#testing) |
|
||||||
|
|
||||||
<!--- END --> |
|
||||||
|
|
||||||
## Testing database migration |
|
||||||
|
|
||||||
### Creating a reference database |
|
||||||
|
|
||||||
Databases are encrypted, the key to decrypt is needed to setup the test. |
|
||||||
A special build property must be enabled to extract it. |
|
||||||
|
|
||||||
Set `vector.debugPrivateData=true` in `~/.gradle/gradle.properties` (to avoid committing by mistake) |
|
||||||
|
|
||||||
Launch the app in your emulator, login and use the app to fill up the database. |
|
||||||
|
|
||||||
Save the key for the tested database |
|
||||||
``` |
|
||||||
RealmKeysUtils W Database key for alias `session_db_fe9f212a611ccf6dea1141777065ed0a`: 935a6dfa0b0fc5cce1414194ed190.... |
|
||||||
RealmKeysUtils W Database key for alias `crypto_module_fe9f212a611ccf6dea1141777065ed0a`: 7b9a21a8a311e85d75b069a343..... |
|
||||||
``` |
|
||||||
|
|
||||||
|
|
||||||
Use the [Device File Explorer](https://developer.android.com/studio/debug/device-file-explorer) to extrat the database file from the emulator. |
|
||||||
|
|
||||||
Go to `data/data/im.vector.app.debug/files/<hash>/` |
|
||||||
Pick the database you want to test (name can be found in SessionRealmConfigurationFactory): |
|
||||||
- crypto_store.realm for crypto |
|
||||||
- disk_store.realm for session |
|
||||||
- etc... |
|
||||||
|
|
||||||
Download the file on your disk |
|
||||||
|
|
||||||
### Testing |
|
||||||
|
|
||||||
Copy the file in `src/AndroidTest/assets` |
|
||||||
|
|
||||||
see `CryptoSanityMigrationTest` or `RealmSessionStoreMigration43Test` for sample tests. |
|
||||||
|
|
||||||
There are already some databases in the assets folder. |
|
||||||
The existing test will properly detect schema changes, and fail with such errors if a migration is missing: |
|
||||||
|
|
||||||
``` |
|
||||||
io.realm.exceptions.RealmMigrationNeededException: Migration is required due to the following errors: |
|
||||||
- Property 'CryptoMetadataEntity.foo' has been added. |
|
||||||
``` |
|
||||||
|
|
||||||
If you want to test properly more complex database migration (dynamic transforms) ensure that the database contains |
|
||||||
the entity you want to migrate. |
|
||||||
|
|
||||||
You can explore the database with [realm studio](https://www.mongodb.com/docs/realm/studio/) if needed. |
|
||||||
|
|
@ -1,106 +0,0 @@ |
|||||||
# Identity server |
|
||||||
|
|
||||||
<!--- TOC --> |
|
||||||
|
|
||||||
* [Introduction](#introduction) |
|
||||||
* [Implementation](#implementation) |
|
||||||
* [Related MSCs](#related-mscs) |
|
||||||
* [Steps and requirements](#steps-and-requirements) |
|
||||||
* [Screens](#screens) |
|
||||||
* [Settings](#settings) |
|
||||||
* [Discovery screen](#discovery-screen) |
|
||||||
* [Set identity server screen](#set-identity-server-screen) |
|
||||||
* [Ref:](#ref:) |
|
||||||
|
|
||||||
<!--- END --> |
|
||||||
|
|
||||||
Issue: #607 |
|
||||||
PR: #1354 |
|
||||||
|
|
||||||
## Introduction |
|
||||||
Identity servers support contact discovery on Matrix by letting people look up Third Party Identifiers to see if the owner has publicly linked them with their Matrix ID. |
|
||||||
|
|
||||||
## Implementation |
|
||||||
|
|
||||||
The current implementation was Inspired by the code from Riot-Android. |
|
||||||
|
|
||||||
Difference though (list not exhaustive): |
|
||||||
- Only API v2 is supported (see https://matrix.org/docs/spec/identity_service/latest) |
|
||||||
- Homeserver has to be up to date to support binding (Versions.isLoginAndRegistrationSupportedBySdk() has to return true) |
|
||||||
- The SDK managed the session and client secret when binding ThreePid. Those data are not exposed to the client. |
|
||||||
- The SDK supports incremental sendAttempt (this is not used by Element) |
|
||||||
- The "Continue" button is now under the information, and not as the same place that the checkbox |
|
||||||
- The app can cancel a binding. Current data are erased from DB. |
|
||||||
- The API (IdentityService) is improved. |
|
||||||
- A new DB to store data related to the identity server management. |
|
||||||
|
|
||||||
Missing features (list not exhaustive): |
|
||||||
- Invite by 3Pid (will be in a dedicated PR) |
|
||||||
- Add email or phone to account (not P1, can be done on Element-Web) |
|
||||||
- List email and phone of the account (could be done in a dedicated PR) |
|
||||||
- Search contact (not P1) |
|
||||||
- Logout from identity server when user sign out or deactivate his account. |
|
||||||
|
|
||||||
## Related MSCs |
|
||||||
The list can be found here: https://matrix.org/blog/2019/09/27/privacy-improvements-in-synapse-1-4-and-riot-1-4 |
|
||||||
|
|
||||||
## Steps and requirements |
|
||||||
|
|
||||||
- Only one identity server by account can be set. The user's choice is stored in account data with key `m.identity_server`. But every clients will managed its own token to log in to the identity server |
|
||||||
```json |
|
||||||
{ |
|
||||||
"type": "m.identity_server", |
|
||||||
"content": { |
|
||||||
"base_url": "https://matrix.org" |
|
||||||
} |
|
||||||
} |
|
||||||
``` |
|
||||||
- The accepted terms are stored in the account data: |
|
||||||
```json |
|
||||||
{ |
|
||||||
"type": "m.accepted_terms", |
|
||||||
"content": { |
|
||||||
"accepted": [ |
|
||||||
"https://vector.im/identity-server-privacy-notice-1" |
|
||||||
] |
|
||||||
} |
|
||||||
} |
|
||||||
``` |
|
||||||
|
|
||||||
- Default identity server URL, from Wellknown data is proposed to the user. |
|
||||||
- Identity server can be set |
|
||||||
- Identity server can be changed on another user's device, so when the change is detected (thanks to account data sync) Element should properly disconnect from a previous identity server (I think it was not the case in Riot-Android, where we keep the token forever) |
|
||||||
- Registration to the identity server is managed with an openId token |
|
||||||
- Terms of service can be accepted when configuring the identity server. |
|
||||||
- Terms of service can be accepted after, if they change. |
|
||||||
- Identity server can be modified |
|
||||||
- Identity server can be disconnected with a warning dialog, with special content if there are current bound 3pid on this identity server. |
|
||||||
- Email can be bound |
|
||||||
- Email can be unbound |
|
||||||
- Phone can be bound |
|
||||||
- Phone can be unbound |
|
||||||
- Look up can be performed, to get matrixIds from local contact book (phone and email): Android permission correctly handled (not done yet) |
|
||||||
- Look up pepper can be updated if it is rotated on the identity server |
|
||||||
- Invitation using 3PID can be done (See #548) (not done yet) |
|
||||||
- Homeserver access-token will never be sent to an identity server |
|
||||||
- When user sign-out: logout from the identity server if any. |
|
||||||
- When user deactivate account: logout from the identity server if any. |
|
||||||
|
|
||||||
## Screens |
|
||||||
|
|
||||||
### Settings |
|
||||||
|
|
||||||
Identity server settings can be accessed from the internal setting of the application, both from "Discovery" section and from identity detail section. |
|
||||||
|
|
||||||
### Discovery screen |
|
||||||
|
|
||||||
This screen displays the identity server configuration and the binding of the user's ThreePid (email and msisdn). This is the main screen of the feature. |
|
||||||
|
|
||||||
### Set identity server screen |
|
||||||
|
|
||||||
This screen is a form to set a new identity server URL |
|
||||||
|
|
||||||
## Ref: |
|
||||||
- https://matrix.org/blog/2019/09/27/privacy-improvements-in-synapse-1-4-and-riot-1-4 is a good summary of the role of an identity server and the proper way to configure and use it in respect to the privacy and the consent of the user. |
|
||||||
- API documentation: https://matrix.org/docs/spec/identity_service/latest |
|
||||||
- vector.im TOS: https://vector.im/identity-server-privacy-notice |
|
@ -1,96 +0,0 @@ |
|||||||
# Jitsi in Element Android |
|
||||||
|
|
||||||
<!--- TOC --> |
|
||||||
|
|
||||||
* [Native Jitsi SDK](#native-jitsi-sdk) |
|
||||||
* [How to build the Jitsi Meet SDK](#how-to-build-the-jitsi-meet-sdk) |
|
||||||
* [Jitsi version](#jitsi-version) |
|
||||||
* [Run the build script](#run-the-build-script) |
|
||||||
* [Link with the new generated library](#link-with-the-new-generated-library) |
|
||||||
* [Sanity tests](#sanity-tests) |
|
||||||
* [Export the build library](#export-the-build-library) |
|
||||||
|
|
||||||
<!--- END --> |
|
||||||
|
|
||||||
Native Jitsi support has been added to Element Android by the PR [#1914](https://github.com/vector-im/element-android/pull/1914). The description of the PR contains some documentation about the behaviour in each possible room configuration. |
|
||||||
|
|
||||||
Also, ensure to have a look on [the documentation from Element Web](https://github.com/vector-im/element-web/blob/develop/docs/jitsi.md) |
|
||||||
|
|
||||||
The official documentation about how to integrate the Jitsi SDK in an Android app is available here: https://jitsi.github.io/handbook/docs/dev-guide/dev-guide-android-sdk. |
|
||||||
|
|
||||||
## Native Jitsi SDK |
|
||||||
|
|
||||||
The Jitsi SDK is built by ourselves with the flag LIBRE_BUILD, to be able to be integrated on the F-Droid version of Element Android. |
|
||||||
|
|
||||||
The generated maven repository is then host in the project https://github.com/vector-im/jitsi_libre_maven |
|
||||||
|
|
||||||
### How to build the Jitsi Meet SDK |
|
||||||
|
|
||||||
#### Jitsi version |
|
||||||
|
|
||||||
Update the script `./tools/jitsi/build_jisti_libs.sh` with the tag of the project `https://github.com/jitsi/jitsi-meet`. |
|
||||||
|
|
||||||
Latest tag can be found from this page: https://github.com/jitsi/jitsi-meet-release-notes/blob/master/CHANGELOG-MOBILE-SDKS.md |
|
||||||
|
|
||||||
Currently we are building the version with the tag `android-sdk-3.10.0`. |
|
||||||
|
|
||||||
#### Run the build script |
|
||||||
|
|
||||||
At the root of the Element Android, run the following script: |
|
||||||
|
|
||||||
```shell script |
|
||||||
./tools/jitsi/build_jisti_libs.sh |
|
||||||
``` |
|
||||||
|
|
||||||
It will build the Jitsi Meet Android library and put every generated files in the folder `/tmp/jitsi` |
|
||||||
|
|
||||||
#### Link with the new generated library |
|
||||||
|
|
||||||
- Update the file `./build.gradle` to use the previously created local Maven repository. Currently we have this line: |
|
||||||
|
|
||||||
```groovy |
|
||||||
url "https://github.com/vector-im/jitsi_libre_maven/raw/master/android-sdk-3.10.0" |
|
||||||
``` |
|
||||||
|
|
||||||
You can uncomment and update the line starting with `// url "file://...` and comment the line starting with `url`, to test the library using the locally generated Maven repository. |
|
||||||
|
|
||||||
- Update the dependency of the Jitsi Meet library in the file `./vector/build.gradle`. Currently we have this line: |
|
||||||
|
|
||||||
```groovy |
|
||||||
implementation('org.jitsi.react:jitsi-meet-sdk:3.10.0') |
|
||||||
``` |
|
||||||
|
|
||||||
- Update the dependency of the WebRTC library in the file `./vector/build.gradle`. Currently we have this line: |
|
||||||
|
|
||||||
```groovy |
|
||||||
implementation('com.facebook.react:react-native-webrtc:1.92.1-jitsi-9093212@aar') |
|
||||||
``` |
|
||||||
|
|
||||||
- Perform a gradle sync and build the project |
|
||||||
- Perform test |
|
||||||
|
|
||||||
#### Sanity tests |
|
||||||
|
|
||||||
In order to validate that the upgrade of the Jitsi and WebRTC dependency does not break anything, the following sanity tests have to be performed, using two devices: |
|
||||||
- Make 1-1 audio call (so using WebRTC) |
|
||||||
- Make 1-1 video call (so using WebRTC) |
|
||||||
- Create and join a conference call with audio only (so using Jitsi library). Leave the conference. Join it again. |
|
||||||
- Create and join a conference call with audio and video (so using Jitsi library) Leave the conference. Join it again. |
|
||||||
|
|
||||||
#### Export the build library |
|
||||||
|
|
||||||
If all the tests are passed, you can export the generated Jitsi library to our Maven repository. |
|
||||||
|
|
||||||
- Clone the project https://github.com/vector-im/jitsi_libre_maven. |
|
||||||
- Create a new folder with the version name. |
|
||||||
- Copy every generated files form `/tmp/jitsi` to the folder you have just created. |
|
||||||
- Commit and push the change on https://github.com/vector-im/jitsi_libre_maven. |
|
||||||
- Update the file `./build.gradle` to use the previously created Maven repository. Currently we have this line: |
|
||||||
|
|
||||||
```groovy |
|
||||||
url "https://github.com/vector-im/jitsi_libre_maven/raw/master/android-sdk-3.10.0" |
|
||||||
``` |
|
||||||
|
|
||||||
- Build the project and perform the sanity tests again. |
|
||||||
|
|
||||||
- Create a PR for project Element Android and add a changelog file `<PR_NUMBER>.misc` to notify about the library upgrade. |
|
@ -1,193 +0,0 @@ |
|||||||
# Automate user interface tests |
|
||||||
|
|
||||||
Element Android ensures that some fundamental flows are properly working by running automated user interface tests. |
|
||||||
Ui tests are using the android [Espresso](https://developer.android.com/training/testing/espresso) library. |
|
||||||
|
|
||||||
Tests can be run on a real device, or on a virtual device (such as the emulator in Android Studio). |
|
||||||
|
|
||||||
Currently the test are covering a small set of application flows: |
|
||||||
- Registration |
|
||||||
- Self verification via emoji |
|
||||||
- Self verification via passphrase |
|
||||||
|
|
||||||
<!--- TOC --> |
|
||||||
|
|
||||||
* [Prerequisites:](#prerequisites:) |
|
||||||
* [Run the tests](#run-the-tests) |
|
||||||
* [From the source code](#from-the-source-code) |
|
||||||
* [From command line](#from-command-line) |
|
||||||
* [Recipes](#recipes) |
|
||||||
* [Wait for initial sync](#wait-for-initial-sync) |
|
||||||
* [Accessing current activity](#accessing-current-activity) |
|
||||||
* [Interact with other session](#interact-with-other-session) |
|
||||||
* [Contributing to the UiAllScreensSanityTest](#contributing-to-the-uiallscreenssanitytest) |
|
||||||
|
|
||||||
<!--- END --> |
|
||||||
|
|
||||||
## Prerequisites: |
|
||||||
|
|
||||||
Out of the box, the tests use one of the homeservers (located at http://localhost:8080) of the "Demo Federation of Homeservers" (https://github.com/matrix-org/synapse#running-a-demo-federation-of-synapses). |
|
||||||
|
|
||||||
You first need to follow instructions to set up Synapse in development mode at https://github.com/matrix-org/synapse#synapse-development. If you have already installed all dependencies, the steps are: |
|
||||||
|
|
||||||
```shell script |
|
||||||
$ git clone https://github.com/matrix-org/synapse.git |
|
||||||
$ cd synapse |
|
||||||
$ virtualenv -p python3 env |
|
||||||
$ source env/bin/activate |
|
||||||
(env) $ python -m pip install --no-use-pep517 -e . |
|
||||||
``` |
|
||||||
|
|
||||||
Every time you want to launch these test homeservers, type: |
|
||||||
|
|
||||||
```shell script |
|
||||||
$ source env/bin/activate |
|
||||||
(env) $ demo/start.sh --no-rate-limit |
|
||||||
``` |
|
||||||
|
|
||||||
**Emulator/Device set up** |
|
||||||
|
|
||||||
When running the test via android studio on a device, you have to disable system animations in order for the test to work properly. |
|
||||||
|
|
||||||
First, ensure developer mode is enabled: |
|
||||||
|
|
||||||
- To enable developer options, tap the **Build Number** option 7 times. You can find this option in one of the following locations, depending on your Android version: |
|
||||||
|
|
||||||
- Android 9 (API level 28) and higher: **Settings > About Phone > Build Number** |
|
||||||
- Android 8.0.0 (API level 26) and Android 8.1.0 (API level 26): **Settings > System > About Phone > Build Number** |
|
||||||
- Android 7.1 (API level 25) and lower: **Settings > About Phone > Build Number** |
|
||||||
|
|
||||||
On your device, under **Settings > Developer options**, disable the following 3 settings: |
|
||||||
|
|
||||||
- Window animation scale |
|
||||||
- Transition animation scale |
|
||||||
- Animator duration scale |
|
||||||
|
|
||||||
## Run the tests |
|
||||||
|
|
||||||
Once Synapse is running, and an emulator is running, you can run the UI tests. |
|
||||||
|
|
||||||
### From the source code |
|
||||||
|
|
||||||
Click on the green arrow in front of each test. Clicking on the arrow in front of the test class, or from the package directory does not always work (Tests not found issue). |
|
||||||
|
|
||||||
### From command line |
|
||||||
|
|
||||||
````shell script |
|
||||||
./gradlew vector:connectedGplayDebugAndroidTest |
|
||||||
```` |
|
||||||
|
|
||||||
To run all the tests from the `vector` module. |
|
||||||
|
|
||||||
In case of trouble, you can try to uninstall the previous installed test APK first with this command: |
|
||||||
|
|
||||||
```shell script |
|
||||||
adb uninstall im.vector.app.debug.test |
|
||||||
``` |
|
||||||
## Recipes |
|
||||||
|
|
||||||
We added some specific Espresso IdlingResources, and other utilities for matrix related tests |
|
||||||
|
|
||||||
### Wait for initial sync |
|
||||||
|
|
||||||
```kotlin |
|
||||||
// Wait for initial sync and check room list is there |
|
||||||
withIdlingResource(initialSyncIdlingResource(uiSession)) { |
|
||||||
onView(withId(R.id.roomListContainer)) |
|
||||||
.check(matches(isDisplayed())) |
|
||||||
} |
|
||||||
``` |
|
||||||
|
|
||||||
### Accessing current activity |
|
||||||
|
|
||||||
```kotlin |
|
||||||
val activity = EspressoHelper.getCurrentActivity()!! |
|
||||||
val uiSession = (activity as HomeActivity).activeSessionHolder.getActiveSession() |
|
||||||
``` |
|
||||||
|
|
||||||
### Interact with other session |
|
||||||
|
|
||||||
It's possible to create a session via the SDK, and then use this session to interact with the one that the emulator is using (to check verifications for example) |
|
||||||
|
|
||||||
```kotlin |
|
||||||
@Before |
|
||||||
fun initAccount() { |
|
||||||
val context = InstrumentationRegistry.getInstrumentation().targetContext |
|
||||||
val matrix = Matrix.getInstance(context) |
|
||||||
val userName = "foobar_${System.currentTimeMillis()}" |
|
||||||
existingSession = createAccountAndSync(matrix, userName, password, true) |
|
||||||
} |
|
||||||
``` |
|
||||||
|
|
||||||
### Contributing to the UiAllScreensSanityTest |
|
||||||
|
|
||||||
The `UiAllScreensSanityTest` makes use of the Robot pattern in order to model pages, components and interactions. |
|
||||||
Each Robot aims to return the UI back to its original state after the interaction, allowing for a reusable and consistent DSL. |
|
||||||
|
|
||||||
```kotlin |
|
||||||
// launches and closes settings after executing the block |
|
||||||
elementRobot.settings { |
|
||||||
// whilst in the settings, launches and closes the advanced settings sub screen |
|
||||||
advancedSettings { |
|
||||||
// crawls all the pages within the advanced settings |
|
||||||
crawl() |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// enables developer mode by navigating to the settings, enabling the toggle and then returning to the starting point to execute the block |
|
||||||
// on block completion the Robot disables developer mode by navigating back to the settings and finally returning to the original starting point |
|
||||||
elementRobot.withDeveloperMode { |
|
||||||
// the same starting point as the example above |
|
||||||
settings { |
|
||||||
advancedSettings { crawlDeveloperOptions() } |
|
||||||
} |
|
||||||
} |
|
||||||
``` |
|
||||||
|
|
||||||
The Robots used in the example above... |
|
||||||
|
|
||||||
```kotlin |
|
||||||
class ElementRobot { |
|
||||||
fun settings(block: SettingsRobot.() -> Unit) { |
|
||||||
// double check we're where we think we are |
|
||||||
waitUntilViewVisible(withId(R.id.bottomNavigationView)) |
|
||||||
|
|
||||||
// navigate to the settings |
|
||||||
openDrawer() |
|
||||||
clickOn(R.id.homeDrawerHeaderSettingsView) |
|
||||||
|
|
||||||
// execute the robot with the context of the settings screen |
|
||||||
block(SettingsRobot()) |
|
||||||
|
|
||||||
// close the settings and ensure we're back at the starting point |
|
||||||
pressBack() |
|
||||||
waitUntilViewVisible(withId(R.id.bottomNavigationView)) |
|
||||||
} |
|
||||||
|
|
||||||
fun withDeveloperMode(block: ElementRobot.() -> Unit) { |
|
||||||
settings { toggleDeveloperMode() } |
|
||||||
block() |
|
||||||
settings { toggleDeveloperMode() } |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
class SettingsRobot { |
|
||||||
fun toggleDeveloperMode() { |
|
||||||
advancedSettings { |
|
||||||
toggleDeveloperMode() |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
fun advancedSettings(block: SettingsAdvancedRobot.() -> Unit) { |
|
||||||
clickOn(R.string.settings_advanced_settings) |
|
||||||
block(SettingsAdvancedRobot()) |
|
||||||
pressBack() |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
class SettingsAdvancedRobot { |
|
||||||
fun toggleDeveloperMode() { |
|
||||||
clickOn(R.string.settings_developer_mode_summary) |
|
||||||
} |
|
||||||
} |
|
||||||
``` |
|
@ -1,351 +0,0 @@ |
|||||||
# Table of Contents |
|
||||||
|
|
||||||
<!--- TOC --> |
|
||||||
|
|
||||||
* [Overview](#overview) |
|
||||||
* [Best Practices](#best-practices) |
|
||||||
* [Project Conventions](#project-conventions) |
|
||||||
* [Setup](#setup) |
|
||||||
* [Naming](#naming) |
|
||||||
* [Format](#format) |
|
||||||
* [Assertions](#assertions) |
|
||||||
* [Constants](#constants) |
|
||||||
* [Mocking](#mocking) |
|
||||||
* [Fakes](#fakes) |
|
||||||
* [Fixtures](#fixtures) |
|
||||||
* [Examples](#examples) |
|
||||||
* [Extensions used to streamline the test setup](#extensions-used-to-streamline-the-test-setup) |
|
||||||
* [Fakes and Fixtures](#fakes-and-fixtures) |
|
||||||
|
|
||||||
<!--- END --> |
|
||||||
|
|
||||||
## Overview |
|
||||||
|
|
||||||
Unit tests are a mechanism to validate our code executes the way we expect. They help to inform the design of our systems by requiring testability and |
|
||||||
understanding, they describe the inner workings without relying on inline comments and protect from unexpected regressions. |
|
||||||
|
|
||||||
However, unit tests are not a magical solution to solve all our problems and come at a cost. Unreliable and hard to maintain tests often end up ignored, deleted |
|
||||||
or worse, provide a false sense of security. |
|
||||||
|
|
||||||
### Best Practices |
|
||||||
|
|
||||||
Tests can be written in many ways, the main rule is to keep them simple and maintainable. Some ways to help achieve this are... |
|
||||||
|
|
||||||
- Break out logic into single units (following the Single Responsibility Principle) to reduce test complexity. |
|
||||||
- Favour pure functions, avoiding mutable state. |
|
||||||
- Prefer dependency injection to static calls to allow for simpler test setup. |
|
||||||
- Write concise tests with a single function under test, clearly showing the inputs and expected output. |
|
||||||
- Create separate test cases instead of changing parameters and grouping multiple assertions within a single test to help trace back failure causes (with the |
|
||||||
exception of parameterised tests). |
|
||||||
- Assert against entire models instead of subsets of properties to capture any possible changes within the test scope. |
|
||||||
- Avoid invoking logic from production instances other than the class under test to guard from unrelated changes. |
|
||||||
- Always inject `Dispatchers` and `Clock` instances and provide fake implementations for tests to avoid non deterministic results. |
|
||||||
|
|
||||||
## Project Conventions |
|
||||||
|
|
||||||
#### Setup |
|
||||||
|
|
||||||
- Test file and class name should be the class under test with the Test suffix, created in a `test` sourceset, with the same package name as the class under |
|
||||||
test. |
|
||||||
- Dependencies of the class are instantiated inline, junit will recreate the test class for each test run. |
|
||||||
- A line break between the dependencies and class under test helps clarify the instance being tested. |
|
||||||
|
|
||||||
```kotlin |
|
||||||
|
|
||||||
class MyClassTest { |
|
||||||
|
|
||||||
private val fakeUppercaser = FakeUppercaser() |
|
||||||
|
|
||||||
// line break between the class under test and its dependencies |
|
||||||
private val myClass = MyClass(fakeUppercaser.instance) |
|
||||||
} |
|
||||||
|
|
||||||
``` |
|
||||||
|
|
||||||
#### Naming |
|
||||||
|
|
||||||
- Test names use the `Gherkin` format, `given, when, then` mapping to the input, logic under test and expected result. |
|
||||||
- `given` - Uniqueness about the environment or dependencies in which the test case is running. _"given device is android 12 and supports dark mode"_ |
|
||||||
- `when` - The action/function under test. _"when reading dark mode status"_ |
|
||||||
- `then` - The expected result from the combination of _given_ and _when_. _"then returns dark mode enabled"_ |
|
||||||
- Test names are written using kotlin back ticks to enable sentences _ish_. |
|
||||||
|
|
||||||
```kotlin |
|
||||||
@Test |
|
||||||
fun `given a lowercase label, when uppercasing, then returns label uppercased` |
|
||||||
``` |
|
||||||
|
|
||||||
When the input is given directly to the _when_, this can also be represented as... |
|
||||||
|
|
||||||
```kotlin |
|
||||||
@Test |
|
||||||
fun `when uppercasing a lowercase label, then returns label uppercased` |
|
||||||
``` |
|
||||||
|
|
||||||
Multiple given or returns statements can be used in the name although it could be a sign that the logic being tested does too much. |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
#### Format |
|
||||||
|
|
||||||
- Test bodies are broken into sections through the use of blank lines where the sections correspond to the test name. |
|
||||||
- Sections can span multiple lines. |
|
||||||
|
|
||||||
```kotlin |
|
||||||
// comments are for illustrative purposes |
|
||||||
/* given */ val lowercaseLabel = "hello world" |
|
||||||
|
|
||||||
/* when */ val result = textUppercaser.uppercase(lowercaseLabel) |
|
||||||
|
|
||||||
/* then */ result shouldBeEqualTo "HELLO WORLD" |
|
||||||
``` |
|
||||||
|
|
||||||
- Functions extracted from test bodies are placed beneath all the unit tests. |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
#### Assertions |
|
||||||
|
|
||||||
- Assertions against test results are made using [Kluent's](https://github.com/MarkusAmshove/Kluent) _fluent_ api. |
|
||||||
- Typically `shouldBeEqualTo`is the main assertion to use for asserting function return values as by project convention we assert against entire objects or |
|
||||||
lists. |
|
||||||
|
|
||||||
```kotlin |
|
||||||
val result = listOf("hello", "world") |
|
||||||
|
|
||||||
// Fail |
|
||||||
result shouldBeEqualTo listOf("hello") |
|
||||||
``` |
|
||||||
|
|
||||||
```kotlin |
|
||||||
data class Person(val age: Int, val name: String) |
|
||||||
|
|
||||||
val result = Person(age = 100, name = "Gandalf") |
|
||||||
|
|
||||||
// Avoid |
|
||||||
result.age shouldBeEqualTo 100 |
|
||||||
|
|
||||||
// Prefer |
|
||||||
result shouldBeEqualTo Person(age = 100, "Gandalf") |
|
||||||
``` |
|
||||||
|
|
||||||
- Exception throwing can be asserted against using `assertFailsWith<T : Throwable>`. |
|
||||||
- When asserting reusable exceptions, include the message to distinguish between them. |
|
||||||
|
|
||||||
```kotlin |
|
||||||
assertFailsWith<ConcreteException>(message = "Details about error") { |
|
||||||
// when section of the test |
|
||||||
codeUnderTest() |
|
||||||
} |
|
||||||
``` |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
#### Constants |
|
||||||
|
|
||||||
- Reusable values are extracted to file level immutable properties or constants. |
|
||||||
- These can be parameters or expected results. |
|
||||||
- The naming convention is to prefix with `A` or `AN` for better matching with the test name. |
|
||||||
|
|
||||||
```kotlin |
|
||||||
private const val A_LOWERCASE_LABEL = "hello" |
|
||||||
|
|
||||||
class MyTest { |
|
||||||
@Test |
|
||||||
fun `when uppercasing a lowercase label, then returns label uppercased`() { |
|
||||||
val result = TextUppercaser().uppercase(A_LOWERCASE_LABEL) |
|
||||||
... |
|
||||||
} |
|
||||||
} |
|
||||||
``` |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
#### Mocking |
|
||||||
|
|
||||||
- In order to provide different behaviour for dependencies within tests our main method is through mocking, using [Mockk](https://mockk.io/). |
|
||||||
- We avoid using relaxed mocks in favour of explicitly declaring mock behaviour through the _Fake_ convention. There are exceptions when mocking framework |
|
||||||
classes which would require a lot of boilerplate. |
|
||||||
- Using `Spy` is discouraged as it inherently requires real instances, which we are avoiding in our tests. There are exceptions such as `VectorFeatures` which |
|
||||||
acts like a `Fixture` in release builds. |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
#### Fakes |
|
||||||
|
|
||||||
- Fakes are reusable instances of classes purely for testing purposes. They provide functions to replace the functions of the interface/class they're faking |
|
||||||
with test specific values. |
|
||||||
- When faking an interface, the _Fake_ can be written using delegation or by stubbing |
|
||||||
- All Fakes currently reside in the same package `${package}.test.fakes` |
|
||||||
|
|
||||||
```kotlin |
|
||||||
// Delegating to a mock |
|
||||||
class FakeClock : Clock by mockk() { |
|
||||||
fun givenEpoch(epoch: Long) { |
|
||||||
every { epochMillis() } returns epoch |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// Stubbing the interface |
|
||||||
class FakeClock(private val epoch: Long) : Clock { |
|
||||||
override fun epochMillis() = epoch |
|
||||||
} |
|
||||||
``` |
|
||||||
|
|
||||||
It's currently more common for fakes to fake class behaviour, we achieve this by wrapping and exposing a mock instance. |
|
||||||
|
|
||||||
```kotlin |
|
||||||
class FakeCursor { |
|
||||||
val instance = mockk<Cursor>() |
|
||||||
fun givenEmpty() { |
|
||||||
every { instance.count } returns 0 |
|
||||||
every { instance.moveToFirst() } returns false |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
val fakeCursor = FakeCursor().apply { givenEmpty() } |
|
||||||
``` |
|
||||||
|
|
||||||
#### Fixtures |
|
||||||
|
|
||||||
- Fixtures are a reusable wrappers around data models. They provide default values to make creating instances as easy as possible, with the option to override |
|
||||||
specific parameters when needed. |
|
||||||
- Are namespaced within an `object`. |
|
||||||
- Reduces the _find usages_ noise when searching for usages of the origin class construction. |
|
||||||
- All Fixtures currently reside in the same package `${package}.test.fixtures`. |
|
||||||
|
|
||||||
```kotlin |
|
||||||
object ContentAttachmentDataFixture { |
|
||||||
fun aContentAttachmentData( |
|
||||||
type: ContentAttachmentData.Type.TEXT, |
|
||||||
mimeType: String? = null |
|
||||||
) = ContentAttachmentData(type, mimeType) |
|
||||||
} |
|
||||||
``` |
|
||||||
|
|
||||||
- Fixtures can also be used to manage specific combinations of parameters |
|
||||||
|
|
||||||
```kotlin |
|
||||||
fun aContentAttachmentAudioData() = aContentAttachmentData( |
|
||||||
type = ContentAttachmentData.Type.AUDIO, |
|
||||||
mimeType = "audio/mp3", |
|
||||||
) |
|
||||||
``` |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
### Examples |
|
||||||
|
|
||||||
##### Extensions used to streamline the test setup |
|
||||||
|
|
||||||
```kotlin |
|
||||||
class CircularCacheTest { |
|
||||||
|
|
||||||
@Test |
|
||||||
fun `when putting more than cache size then cache is limited to cache size`() { |
|
||||||
val (cache, internalData) = createIntCache(cacheSize = 3) |
|
||||||
|
|
||||||
cache.putInOrder(1, 1, 1, 1, 1, 1) |
|
||||||
|
|
||||||
internalData shouldBeEqualTo arrayOf(1, 1, 1) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
private fun createIntCache(cacheSize: Int): Pair<CircularCache<Int>, Array<Int?>> { |
|
||||||
var internalData: Array<Int?>? = null |
|
||||||
val factory: (Int) -> Array<Int?> = { |
|
||||||
Array<Int?>(it) { null }.also { array -> internalData = array } |
|
||||||
} |
|
||||||
return CircularCache(cacheSize, factory) to internalData!! |
|
||||||
} |
|
||||||
|
|
||||||
private fun CircularCache<Int>.putInOrder(vararg values: Int) { |
|
||||||
values.forEach { put(it) } |
|
||||||
} |
|
||||||
``` |
|
||||||
|
|
||||||
##### Fakes and Fixtures |
|
||||||
|
|
||||||
```kotlin |
|
||||||
class LateInitUserPropertiesFactoryTest { |
|
||||||
|
|
||||||
private val fakeActiveSessionDataSource = FakeActiveSessionDataSource() |
|
||||||
private val fakeVectorStore = FakeVectorStore() |
|
||||||
private val fakeContext = FakeContext() |
|
||||||
private val fakeSession = FakeSession().also { |
|
||||||
it.givenVectorStore(fakeVectorStore.instance) |
|
||||||
} |
|
||||||
|
|
||||||
private val lateInitUserProperties = LateInitUserPropertiesFactory( |
|
||||||
fakeActiveSessionDataSource.instance, |
|
||||||
fakeContext.instance |
|
||||||
) |
|
||||||
|
|
||||||
@Test |
|
||||||
fun `given no active session, when creating properties, then returns null`() { |
|
||||||
val result = lateInitUserProperties.createUserProperties() |
|
||||||
|
|
||||||
result shouldBeEqualTo null |
|
||||||
} |
|
||||||
|
|
||||||
@Test |
|
||||||
fun `given a teams use case set on an active session, when creating properties, then includes the remapped WorkMessaging selection`() { |
|
||||||
fakeVectorStore.givenUseCase(FtueUseCase.TEAMS) |
|
||||||
fakeActiveSessionDataSource.setActiveSession(fakeSession) |
|
||||||
|
|
||||||
val result = lateInitUserProperties.createUserProperties() |
|
||||||
|
|
||||||
result shouldBeEqualTo UserProperties( |
|
||||||
ftueUseCaseSelection = UserProperties.FtueUseCaseSelection.WorkMessaging |
|
||||||
) |
|
||||||
} |
|
||||||
} |
|
||||||
``` |
|
||||||
|
|
||||||
##### ViewModel |
|
||||||
|
|
||||||
- `ViewModels` tend to be one of the most complex areas to unit test due to their position as a coordinator of data flows and bridge between domains. |
|
||||||
- As the project uses a slightly tweaked`MvRx`, our API for the `ViewModel` is simplified down to `input - ViewModel.handle(Action)` |
|
||||||
and `output Flows - ViewModel.viewEvents & ViewModel.stateFlow`. A `ViewModel` test asserter has been created to further simplify the process. |
|
||||||
|
|
||||||
```kotlin |
|
||||||
class ViewModelTest { |
|
||||||
|
|
||||||
private var initialState = ViewState.Empty |
|
||||||
|
|
||||||
@get:Rule |
|
||||||
val mavericksTestRule = MavericksTestRule(testDispatcher = UnconfinedTestDispatcher()) |
|
||||||
|
|
||||||
@Test |
|
||||||
fun `when handling MyAction, then emits Loading and Content states`() { |
|
||||||
val viewModel = ViewModel<State>(initialState) |
|
||||||
val test = viewModel.test() // must be invoked before interacting with the VM |
|
||||||
|
|
||||||
viewModel.handle(MyAction) |
|
||||||
|
|
||||||
test |
|
||||||
.assertViewStates(initialState, State.Loading, State.Content()) |
|
||||||
.assertNoEvents() |
|
||||||
.finish() |
|
||||||
} |
|
||||||
} |
|
||||||
``` |
|
||||||
|
|
||||||
- `ViewModels` often emit multiple states which are copies of the previous state, the `test` extension `assertStatesChanges` allows only the difference to be |
|
||||||
supplied. |
|
||||||
|
|
||||||
```kotlin |
|
||||||
data class ViewState(val name: String? = null, val age: Int? = null) |
|
||||||
val initialState = ViewState() |
|
||||||
val viewModel = ViewModel<State>(initialState) |
|
||||||
val test = viewModel.test() |
|
||||||
|
|
||||||
viewModel.handle(ChangeNameAction("Gandalf")) |
|
||||||
|
|
||||||
test |
|
||||||
.assertStatesChanges( |
|
||||||
initialState, |
|
||||||
{ copy(name = "Gandalf") }, |
|
||||||
) |
|
||||||
.finish() |
|
||||||
``` |
|
@ -1,22 +0,0 @@ |
|||||||
<!--- TOC --> |
|
||||||
|
|
||||||
* [VersionCatalog](#versioncatalog) |
|
||||||
* [Jetpack Compose](#jetpack-compose) |
|
||||||
|
|
||||||
<!--- END --> |
|
||||||
|
|
||||||
### VersionCatalog |
|
||||||
|
|
||||||
https://docs.gradle.org/current/userguide/platforms.html |
|
||||||
|
|
||||||
### Jetpack Compose |
|
||||||
|
|
||||||
https://developer.android.com/jetpack/compose/mental-model |
|
||||||
https://developer.android.com/jetpack/compose/libraries |
|
||||||
https://developer.android.com/jetpack/compose/modifiers-list |
|
||||||
|
|
||||||
https://android.googlesource.com/platform/frameworks/support/+/androidx-main/compose/docs/compose-api-guidelines.md#api-guidelines-for-jetpack-compose |
|
||||||
|
|
||||||
Preview |
|
||||||
https://alexzh.com/jetpack-compose-preview/ |
|
||||||
https://github.com/airbnb/Showkase |
|
Loading…
Reference in new issue