viewmodel-stub
Scans your Kotlin source for @ViewModelStub-annotated classes
and automatically generates a matching interface and a no-op stub
implementation — so you never write preview stubs or test fakes by hand again.
Overview
ViewModels in Android are hard to test and hard to use in Compose previews because they carry real dependencies. The conventional solution is to extract an interface and maintain a hand-written stub — work that is repetitive and easy to let drift out of sync with the real implementation.
viewmodel-stub automates this. Annotate your ViewModel
implementation class with @ViewModelStub, and the plugin generates
two files on every build:
- An interface exposing the public API surface.
- A stub class implementing that interface with sensible default values for all state properties and no-op method bodies.
The generated stub is ready to drop into a Compose @Preview or a
JUnit test without any manual maintenance.
Public properties and methods become an interface you can inject against.
StateFlow, LiveData, collections — all get correct default values.
Suspend functions are preserved with the correct signature in generated code.
Runs before every Kotlin compile. Output is always fresh.
Installation
Add Maven Central to your plugin repositories in settings.gradle.kts:
pluginManagement {
repositories {
mavenCentral()
gradlePluginPortal()
}
}
Then apply the plugin in your Android module's build.gradle.kts:
plugins {
id("com.rohittp.plugables.viewmodel-stub") version "1.0.0"
}
No DSL block is required if your Kotlin sources are in the default location
(src/main/kotlin). The output directory also has a sensible default.
Requires AGP 7.2+
Generated sources are wired via the Android Variant API
(AndroidComponentsExtension), which requires Android Gradle
Plugin 7.2 or higher.
To customise either path, add a viewModelStub block:
viewModelStub {
// Optional — defaults shown
sourceDir.set(file("src/main/kotlin"))
outputDir.set(layout.buildDirectory.dir("generated/source/viewModelStubs/main"))
}
The @ViewModelStub Annotation
The @ViewModelStub annotation is generated by the plugin itself on
first run. You do not need to add any dependency to use it — it will appear in
the generated sources directory and be available to the compiler automatically.
// Generated — do not edit
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class ViewModelStub
Naming convention required
The annotated class must have a name ending in Impl
(e.g., HomeViewModelImpl). The plugin derives the interface name by
stripping the suffix, and the stub name by replacing it with Stub.
Name derivation
| Annotated class | Generated interface | Generated stub |
|---|---|---|
HomeViewModelImpl |
HomeViewModel |
HomeViewModelStub |
ProfileViewModelImpl |
ProfileViewModel |
ProfileViewModelStub |
SearchViewModelImpl |
SearchViewModel |
SearchViewModelStub |
How It Works
The plugin registers a single Gradle task, generateViewModelStubs,
which runs before any Kotlin compilation. The task performs four steps:
The parser is regex-based and intentionally lightweight — it does not invoke
the Kotlin compiler. It extracts the public API surface of the annotated class:
all public val/var properties with explicit type
annotations, and all public (including suspend) functions.
The output directory is wired into all Android build variants using the
Variant API (AndroidComponentsExtension.onVariants), making
generated classes available to the compiler immediately without
afterEvaluate.
Configuration
The viewModelStub DSL block exposes two properties:
| Property | Type | Default | Description |
|---|---|---|---|
sourceDir |
DirectoryProperty |
src/main/kotlin |
Root directory scanned for *.kt files. |
outputDir |
DirectoryProperty |
build/generated/source/viewModelStubs/main |
Where generated .kt files are written. Automatically added to the AGP main source set. |
Full Example
Here is a complete walkthrough from annotated ViewModel to generated output.
Input ViewModel
Write your ViewModel implementation class as normal. Add
@ViewModelStub above the class declaration.
package com.example.ui.home
import com.rohittp.plugables.viewmodelstub.ViewModelStub
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ViewModelStub
class HomeViewModelImpl : ViewModel() {
override val isLoading: StateFlow<Boolean> = MutableStateFlow(false)
override val errorMessage: String? = null
override val items: StateFlow<List<Item>> = MutableStateFlow(emptyList())
override fun loadData(userId: String) {
/* ... real implementation ... */
}
override suspend fun refresh() {
/* ... real implementation ... */
}
}
Generated Interface
The plugin writes HomeViewModel.kt to the output directory.
The interface mirrors every public property and method.
// GENERATED by GenerateViewModelStubsTask — do not edit.
package com.example.ui.home
import kotlinx.coroutines.flow.StateFlow
interface HomeViewModel {
val isLoading: StateFlow<Boolean>
val errorMessage: String?
val items: StateFlow<List<Item>>
fun loadData(
userId: String
)
suspend fun refresh()
}
Generated Stub
The plugin also writes HomeViewModelStub.kt. Every property has a
sensible default; every method has a no-op body (or returns a default value).
The stub is immediately usable in previews and tests.
// GENERATED by GenerateViewModelStubsTask — do not edit.
package com.example.ui.home
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class HomeViewModelStub(
override val isLoading: StateFlow<Boolean> = MutableStateFlow(false),
override val errorMessage: String? = null,
override val items: StateFlow<List<Item>> = MutableStateFlow(emptyList()),
) : HomeViewModel {
override fun loadData(userId: String) {}
override suspend fun refresh() {}
}
Using the stub in a Compose preview
@Preview
@Composable
fun HomeScreenPreview() {
HomeScreen(viewModel = HomeViewModelStub())
}
// Or customise the stub for a specific preview state
@Preview
@Composable
fun HomeScreenLoadingPreview() {
HomeScreen(
viewModel = HomeViewModelStub(
isLoading = MutableStateFlow(true),
)
)
}
Parser Behavior
The parser is intentionally simple and regex-based — it does not resolve types or run the Kotlin compiler. Understanding its scope helps you use the plugin effectively.
What is extracted
- All
public(and package-private)val/varproperties with an explicit type annotation. - All
publicandsuspendfunctions at the class body level. - Package declaration and all import statements (for use in generated files).
What is skipped
private,protected, andinternalmembers.- Properties without an explicit type (e.g.,
val x = 42— no type). - Companion objects,
initblocks, nested classes, inner classes. - Members at depth > 1 (nested in lambdas, inner blocks, etc.).
Limitation
Because the parser does not resolve types, it copies import statements verbatim. If a type used in the ViewModel is not imported (e.g., it is in the same package), the generated files will compile fine. If a type is imported but unused after extraction, the Kotlin compiler will issue a warning.
Type Defaults
The stub generator maps each property type to a default value expression. The following table shows the built-in mappings:
| Type | Default expression |
|---|---|
T? | null |
Boolean | false |
Int | 0 |
Long | 0L |
Float | 0f |
Double | 0.0 |
String | "" |
StateFlow<T> | MutableStateFlow(<default for T>) |
MutableStateFlow<T> | MutableStateFlow(<default for T>) |
SharedFlow<T> | MutableSharedFlow() |
LiveData<T> | MutableLiveData(<default for T>) |
List<T> | emptyList() |
Map<K, V> | emptyMap() |
Set<T> | emptySet() |
CoroutineScope | CoroutineScope(SupervisorJob()) |
IntRange | 0..0 |
ClosedFloatingPointRange<T> | 0f..1f |
| anything else | TODO("Provide default for T") |
For types that fall through to TODO(), the stub will still compile
but will throw a NotImplementedError if that property is accessed
during a test or preview. Replace it by providing the value via the constructor
parameter.