# Testing

> How the test suite is organized: JVM unit tests, Compose and instrumented device tests, and the Rust host tests for the grammar engine.

URL: https://wmkeyboard.pages.dev/development/testing/
Section: Development

WM Keyboard's tests span three runners: plain JVM unit tests, Compose and instrumented tests that need a real device or emulator, and a small Rust suite for the offline grammar engine. Which one a piece of code gets depends on whether it touches the Android framework at all.

## Running the JVM suite

```sh
./gradlew unitTests
```

It needs a Rust toolchain (`cargo` on the PATH or in `~/.cargo/bin`, from [rustup](https://rustup.rs)): the transliteration tests reach the text engines through JNI, so the test tasks of `:core:language` and `:app` first build `native/wm-engine-jni` for the machine they run on. Rust is not needed for an APK, which carries the committed `.so` files.

`unitTests` is a task in the root build, and it exists because no single task name reaches the whole project. `:app` carries the `languages` flavour dimension and the library modules do not, so `:app`'s suite is `testIntlDebugUnitTest` while every `core/*` and `feature/*` module declares `testDebugUnitTest`. Running the bare `testDebugUnitTest` therefore runs the libraries and silently skips all of `:app`, which is most of the tests. `unitTests` depends on both spellings. Add `-Pwmkb.enableFdroid=true` to run the suites against F-Droid's build. See [building from source](https://wmkeyboard.pages.dev/development/building/) for what that changes.

Almost all of it lives in one place:

| Module | JVM test files |
|---|---|
| `app` | 434 |
| `feature/ime` | 134 |
| `core/tools` | 25 |
| `core/language` | 23 |
| `feature/tools` | 12 |
| `core/keyman` | 10 |
| `core/theme` | 9 |
| `core/common` | 8 |
| `core/intelligence` | 6 |
| `core/emoji` | 2 |
| `core/input` | 1 |
| `tools/architecture` | 1 (the [architecture rules](#architecture-rules)) |

434 of 665 test files (about two in three) sit in `app/src/test/`, and 11 of the 21 `core/*`/`feature/*` modules have no test source set at all. That's a leftover of how the codebase was split into modules rather than a rule anyone enforces. Tests mostly stayed where they were written. It also means `./gradlew unitTests` is the command you want almost all the time, since scoping to a single module rarely saves you much.

## Coverage

```sh
./gradlew koverHtmlReportUnit     # build/reports/kover/htmlUnit/index.html
./gradlew koverXmlReportUnit      # build/reports/kover/reportUnit.xml
```

[Kover](https://github.com/Kotlin/kotlinx-kover) runs the debug unit tests of every module and merges them into one report. The merge is the point. Most tests live in `:app` and exercise `core/*` classes, so a single module's own report would say a library is barely tested when `:app` covers it thoroughly. Generated code (resource ids, `BuildConfig`, serializers, Compose's singletons) and `@Preview` functions are left out. Use the `Unit` tasks rather than the plain `koverHtmlReport`, which runs every variant's tests (en, release, fast) over the same classes.

## Testing flows

Code that publishes a `Flow` or `StateFlow` is tested with [Turbine](https://github.com/cashapp/turbine), which makes a test account for every emission: an extra one fails the test as surely as a missing one does. That matters when the flow drives a UI. `ShiftStateFlowTest` watches the keyboard's published state while keys are typed on a real `WMKeyboardService` under Robolectric: a shifted letter lets go of the shift exactly once, and caps lock survives typing without announcing anything. `NetLogFlowTest` checks the network log's two flows, what is in flight and when the log changed. A request that started, a second `end()` from a `finally`, or a request begun while logging was off must not make the settings screen re-read the log.

## Architecture rules

```sh
./gradlew :tools:architecture:test
```

`tools/architecture` is a test-only JVM module that reads the whole source tree with [Konsist](https://docs.konsist.lemonappdev.com/). It checks rules that compile fine when broken, pass every other test, and cost something later. `unitTests` runs it with everything else:

- **F-Droid stubs offer what main code uses of the google sources.** `src/main` compiles against `src/google` or, in F-Droid's build, `src/nogoogle`, so a name it uses has to exist in both. The rule fails on a stub with no google file at the same path, a stub declaring something the google side doesn't, and main code naming a top-level declaration or an object member that only the google side declares. Locally an F-Droid build is the only other thing that would notice, and that is almost never run.
- **Key composables never take `KeyboardUiState`.** `KeyRow`, `KeyCell`, `KeyButton` and `KeyContent` take a resolved `KeyVisual`. Taking the whole state would make every key recompose on every keystroke (see [Performance tooling](https://wmkeyboard.pages.dev/development/performance/#why-does-a-composable-recompose)).
- **Nothing launches on `GlobalScope`.**
- **Every Robolectric test runs on a pinned SDK**, through its source set's `robolectric.properties` or its own `@Config(sdk = …)`. `compileSdk` is the minor level 36.1, for which Robolectric has no runtime.
- **No Kotlin source holds a raw NUL byte.** One compiles and behaves, but `grep` then skips the file and `git` diffs it as binary.
- **The dictionary compiler's shared sources exist and stand alone.** `:tools:dictc` compiles a named list of `core/prediction` files on a plain JVM, and a renamed file drops out of that list silently.
- **Composers are named `*Composer`**, and the keyboard's trace sections are unique and prefixed `WM:`, which the profiling script and its query rely on.

The module scans directories, never the project root: the checkout can hold session worktrees under `.claude/`, each a full copy of the tree.

## Instrumented and Compose UI tests

Some things can't be checked without a device or emulator: real Compose layout, and anything that leans on the platform's actual implementation of an API rather than the desktop JVM's. Those live under `src/androidTest/`, and only `app` has any, 14 files in total. Run them from Android Studio with a device or emulator attached (right-click the test class or the `androidTest` source set, then choose Run), or through Gradle's connected-device test task for the flavor you're building.

Ten of the fourteen are CJK input method end-to-end tests: Pinyin, stroke input, Japanese romaji and flick, dictionary download resume, and so on. See [Chinese, Japanese & Korean](https://wmkeyboard.pages.dev/languages/cjk/) for what that input method does. The other four are a mixed set, and two of them are worth knowing by name:

- **`app/src/androidTest/.../ime/ui/KeyRowsVisibilityTest.kt`** composes the real keyboard screen with `createComposeRule()` and asserts that the key grid is on screen while a plugin panel is open. It's the enforcement half of a pair. A companion JVM test reads the source for the same bug pattern, a panel that shrinks to make room for its own UI but forgets to draw the keys underneath. That source check is cheap, and it would keep passing if the call site were reworded instead of fixed. `KeyRowsVisibilityTest` composes the actual screen, so it only passes when the keys really render.
- **`app/src/androidTest/.../core/icons/SvgParserAndroidTest.kt`** exists because of a bug the JVM suite structurally cannot see. The SVG parser asks `SAXParserFactory` to disable a Xerces-specific parser feature. Desktop Java accepts the call, but Android's SAX implementation throws for anything other than the two `namespaces` features. The call sat inside `runCatching`, so on a real phone every icon silently failed to parse and every icon pack import was broken, while the JVM suite stayed green. Anything that leans on Android's own implementation of a standard API needs a test running on the platform.

Robolectric now gives a JVM unit test a simulated `Context` or `View` when it needs one. `GlideServiceHarness.kt` uses it to drive `WMKeyboardService` itself, right on the JVM. Reach for an instrumented test only when the code leans on the platform's own implementation of an API, not Robolectric's simulation of it.

## Native tests

The text engines are a Rust crate, `native/wm-engine`, and their tests live with them:

```sh
cd native/wm-engine
cargo test
```

Every phonetic engine, Khipro against its four upstream conformance tables, Bijoy ANSI for all three encodings, the grapheme helpers, the romanizers, the key maps and the word-list folds: 290 cases, mirrored from the Kotlin suites the package carried before the port. The JVM side keeps one smoke test, `EngineNativeTest` in `:core:language`, that calls each bridge entry point once to check that the library loads, that strings cross intact and that the enums' ordinals line up. A Khipro failure against a table is a disagreement with upstream: fix the interpreter, never the table.

The same crate holds the SIMD kernels under `src/kernels/`. Each kernel's tests compare its NEON version against its scalar reference on seeded random inputs, over lengths that hit every tail case, within a tolerance the kernel states (exact for integer kernels). They run on an arm64 machine; elsewhere only the reference is built. `NativeEngineTest` in `:core:language` checks the shared bridge plumbing from the JVM: the library loads, the switch turns the fast path off and on, and a batched kernel call crosses arrays both ways.

Prediction's walks, stores and glide decoders run in Rust. The Kotlin implementations they replaced live in the test sources under `reference/` (in `app/src/test/.../prediction` and `.../gesture`), and the `Native*ParityTest` suites in `:app` run each one against its Rust replacement on the same inputs and require bit-identical results. A change to the scoring has to land in both, or those suites fail. `WM_NATIVE=kotlin` in the environment of the Gradle command pins the SIMD kernels to their scalar references; a `-Dwmkeyboard.native=kotlin` on the Gradle command line does not reach the forked test JVM.

The offline grammar tool is backed by a Rust crate (`native/harper-jni`, wrapping [Harper](https://github.com/automattic/harper)'s `harper-core`), and it carries its own host-side test suite, independent of Gradle:

```sh
cd native/harper-jni
cargo test
```

Two tests today. One asserts that a sentence with obvious errors ("He go to the store yesterday.") produces a non-empty lint result. The other is a regression guard that the lint pipeline doesn't panic on text containing an emoji, which checks the UTF-16-versus-character offset math the JNI bridge has to get right when it hands span positions back to Kotlin. `cargo test` exercises the lint-to-JSON path directly, with no Android involved. Cross-compiling the crate down to `libharper_jni.so` for the app is a separate step, covered on the [building from source](https://wmkeyboard.pages.dev/development/building/) page.

## Why the engine logic is JVM-testable at all

It would be easy to assume "core modules don't touch Android," but that isn't quite right. Every one of them is an Android module: the 17 `core/*` and three of the four `feature/*` apply `com.android.library`, the Play-only `feature/llm` applies `com.android.dynamic-feature`, and all of them declare a `minSdk`. Most mix framework-coupled code with framework-free logic in the same module. What makes a class testable on the JVM is that *it specifically* avoids `android.*`/`androidx.*` imports, not that its module does.

Even the modules that look closest to framework-free aren't. `core/icons`, `core/addons` and `core/plugins` (the plugin runtime) are light on `android.*`/`androidx.*` references, but each has one file (`IconPackStore.kt`, `AddonStore.kt` and `PluginStore.kt`) whose companion object takes an `android.content.Context` to resolve its on-disk storage directory, and a few more that reach for `androidx.annotation.StringRes` or a `Context` to resolve a label. Everything else in those three modules is plain Kotlin, which is why the parsers and the sandbox are JVM-testable while the stores are not.

`core/tools` shows the same split at a different ratio: 22 of its 59 source files import `android.*` or `androidx.*`, mostly for things like the calendar provider, and the 18 classes with unit tests sit on both sides of the line. `DictionaryClient.kt` has no Android imports at all; `WeatherClient.kt` and `CalendarDefaults.kt` pull in `androidx.annotation.StringRes` and the module's own `R` for their user-facing strings, which the JVM suite is happy with because neither touches a `Context`. The [dictionary compiler](https://wmkeyboard.pages.dev/development/dictionaries/) is the clean case: `tools/dictc` is the one pure-JVM module in the tree (plain `kotlin("jvm")`, no Android plugin). It works because it reuses `core/prediction`'s trie and codec source files directly. Those were already framework-free, so the same `.kt` files compile standalone into the offline tool that builds `.wmdict` files.

## Testing on a device

development/diagnostics-screen — Settings → About → Diagnostics: filterable app log, crash records, and a one-tap report export.

Once you've [built and installed](https://wmkeyboard.pages.dev/development/building/) a flavor, set WM Keyboard as your active input method before you test gesture or layout changes. To check which IME is currently active, run `adb shell dumpsys input_method | grep mCurId`.

When something breaks while you're testing by hand, check About / Diagnostics before you go looking for a logcat. It's an always-on, in-memory ring buffer the keyboard writes to itself, so there's no debug flag to remember to flip before you reproduce the problem. Alongside it sits a small crash record kept in device-protected storage, which means a crash on the lock screen survives the process dying.

The screen's own intro line is exact about what's in it: "The keyboard never records what you type." Call sites pass what happened, never what you typed, which is what makes the report safe to paste into an issue without reading it line by line first. There's also an opt-in **Include the system log** toggle, off by default, that adds this process's own logcat output, meaning everything the app's libraries printed rather than only what the keyboard chose to record. Turn it on for a hard-to-reproduce bug. Leave it off otherwise, since the keyboard doesn't control what ends up in it.

## Profiling

Performance has a page of its own: [Performance tooling](https://wmkeyboard.pages.dev/development/performance/) covers frame stats, Perfetto traces and the keyboard's own trace sections, jank logging, Compose compiler reports, the macrobenchmarks and the baseline profile.

## Details & edge cases

- **The build switch is a property, not a task name.** `testDebugUnitTest` runs against the standard dependency set (ML Kit, LiteRT, and the rest). If you're chasing a bug that only reproduces in the F-Droid build, add `-Pwmkb.enableFdroid=true`, not a different module scope. And in `:app` alone the name carries the language flavour, so it is `testIntlDebugUnitTest` there.
- **A green JVM suite doesn't mean the feature works on a phone.** The `SvgParserAndroidTest` story above is the concrete example: the JVM's XML stack silently tolerated a call Android's SAX parser rejects outright. If a class calls into an Android or platform API rather than pure Kotlin, treat JVM-green as provisional until it has also run on a device.
- **`app` is where almost everything lives, tests included.** If you're adding tests for new logic and the surrounding code is in `app/src/main`, its test belongs in `app/src/test` alongside the rest. That isn't an oversight. It matches where about two thirds of the existing suite already sits.
- Static analysis (`./gradlew staticAnalysis`, lint plus detekt) is a separate gate from either test suite. It's covered on the [building from source](https://wmkeyboard.pages.dev/development/building/) page, not here.

Found a bug while testing and want to file it well? Pull the report from About / Diagnostics rather than describing what you saw from memory. See [privacy at a glance](https://wmkeyboard.pages.dev/privacy/overview/) for what else the app does and doesn't record about you.
