Skip to content
WM KeyboardWM Keyboard
Accessibility

Testing

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

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.

Terminal window
./gradlew unitTests

It needs a Rust toolchain (cargo on the PATH or in ~/.cargo/bin, from rustup): 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 a bare flavored task name no longer reaches the whole project. :app carries the languages flavour dimension and the library modules do not, so :app’s suite is testFullIntlDebugUnitTest while every core/* and feature/* module still declares testFullDebugUnitTest. Running the bare testFullDebugUnitTest therefore runs the libraries and silently skips all of :app, which is most of the tests. unitTests depends on both spellings. Swap in testLiteDebugUnitTest (or :app:testLiteIntlDebugUnitTest) to run a suite against the lite flavor. See building from source for what the flavors change.

Almost all of it lives in one place:

ModuleJVM test files
app434
feature/ime134
core/tools25
core/language23
feature/tools12
core/keyman10
core/theme9
core/common8
core/intelligence6 (three in src/testFull/, full-flavor only)
core/emoji2
core/input1
tools/architecture1 (the 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.

Terminal window
./gradlew koverHtmlReportUnit # build/reports/kover/htmlUnit/index.html
./gradlew koverXmlReportUnit # build/reports/kover/reportUnit.xml

Kover runs the full-flavor 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 (lite, release, fast) over the same classes.

Code that publishes a Flow or StateFlow is tested with 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.

Terminal window
./gradlew :tools:architecture:test

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

  • Lite stubs offer what main code uses of the full flavor. src/main compiles against whichever flavor is being built, so a name it uses has to exist in both. The rule fails on a stub with no full file at the same path, a stub declaring something the full flavor doesn’t, and main code naming a top-level declaration or an object member that only the full flavor declares. Locally a lite 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).
  • 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.

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 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.

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

Terminal window
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 offline grammar tool is backed by a Rust crate (native/harper-jni, wrapping Harper’s harper-core), and it carries its own host-side test suite, independent of Gradle:

Terminal window
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 page.

Why the engine logic is JVM-testable at all

Section titled “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 Lua sandbox) 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 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.

Settings → About → Diagnostics: filterable app log, crash records, and a one-tap report export.
Settings → About → Diagnostics: filterable app log, crash records, and a one-tap report export.
Settings → About → Diagnostics: filterable app log, crash records, and a one-tap report export.

Once you’ve built and installed 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 WM Keyboard  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.

Performance has a page of its own: Performance tooling covers frame stats, Perfetto traces and the keyboard’s own trace sections, jank logging, Compose compiler reports, the macrobenchmarks and the baseline profile.

  • The unit-test task name is flavor-specific, not module-specific. testFullDebugUnitTest runs against the full flavor’s dependency set (ML Kit, Whisper, and the rest). If you’re chasing a bug that only reproduces in the lite build, you need testLiteDebugUnitTest, not a different module scope. And in :app alone the name carries the language flavour too, so it is testFullIntlDebugUnitTest 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 page, not here.

Found a bug while testing and want to file it well? Pull the report from WM Keyboard  About  Diagnostics rather than describing what you saw from memory. See privacy at a glance for what else the app does and doesn’t record about you.