Repository: cashapp/redwood
Pull request #787 (2023-02-02 23:49:58)

Generate testing APIs


Patch:
diff --git a/redwood-cli/src/main/kotlin/app/cash/redwood/cli/GenerateCommand.kt b/redwood-cli/src/main/kotlin/app/cash/redwood/cli/GenerateCommand.kt
index ef4b70e48..d2348546a 100644
--- a/redwood-cli/src/main/kotlin/app/cash/redwood/cli/GenerateCommand.kt
+++ b/redwood-cli/src/main/kotlin/app/cash/redwood/cli/GenerateCommand.kt
@@ -38,6 +38,7 @@ internal class GenerateCommand : CliktCommand(name = "generate") {
       "--compose" to CodegenType.Compose,
       "--compose-protocol" to ProtocolCodegenType.Compose,
       "--layout-modifiers" to CodegenType.LayoutModifiers,
+      "--testing" to CodegenType.Testing,
       "--widget" to CodegenType.Widget,
       "--widget-protocol" to ProtocolCodegenType.Widget,
     )
diff --git a/redwood-gradle-plugin/build.gradle b/redwood-gradle-plugin/build.gradle
index ea83f46d5..5f6fc5f17 100644
--- a/redwood-gradle-plugin/build.gradle
+++ b/redwood-gradle-plugin/build.gradle
@@ -54,6 +54,12 @@ gradlePlugin {
       description = "Redwood schema layout modifier code generation Gradle plugin"
       implementationClass = "app.cash.redwood.gradle.RedwoodLayoutModifiersGeneratorPlugin"
     }
+    redwoodTestingGenerator {
+      id = "app.cash.redwood.generator.testing"
+      displayName = "Redwood generator (testing)"
+      description = "Redwood schema testing code generation Gradle plugin"
+      implementationClass = "app.cash.redwood.gradle.RedwoodTestingGeneratorPlugin"
+    }
     redwoodWidgetGenerator {
       id = "app.cash.redwood.generator.widget"
       displayName = "Redwood generator (widget)"
diff --git a/redwood-gradle-plugin/src/main/kotlin/app/cash/redwood/gradle/RedwoodGeneratorPlugin.kt b/redwood-gradle-plugin/src/main/kotlin/app/cash/redwood/gradle/RedwoodGeneratorPlugin.kt
index 880a34219..6b8ad6b35 100644
--- a/redwood-gradle-plugin/src/main/kotlin/app/cash/redwood/gradle/RedwoodGeneratorPlugin.kt
+++ b/redwood-gradle-plugin/src/main/kotlin/app/cash/redwood/gradle/RedwoodGeneratorPlugin.kt
@@ -18,6 +18,7 @@ package app.cash.redwood.gradle
 import app.cash.redwood.gradle.RedwoodGeneratorPlugin.Strategy.Compose
 import app.cash.redwood.gradle.RedwoodGeneratorPlugin.Strategy.ComposeProtocol
 import app.cash.redwood.gradle.RedwoodGeneratorPlugin.Strategy.LayoutModifiers
+import app.cash.redwood.gradle.RedwoodGeneratorPlugin.Strategy.Testing
 import app.cash.redwood.gradle.RedwoodGeneratorPlugin.Strategy.Widget
 import app.cash.redwood.gradle.RedwoodGeneratorPlugin.Strategy.WidgetProtocol
 import org.gradle.api.Plugin
@@ -35,6 +36,9 @@ public class RedwoodComposeProtocolGeneratorPlugin : RedwoodGeneratorPlugin(Comp
 @Suppress("unused") // Invoked reflectively by Gradle.
 public class RedwoodLayoutModifiersGeneratorPlugin : RedwoodGeneratorPlugin(LayoutModifiers)
 
+@Suppress("unused") // Invoked reflectively by Gradle.
+public class RedwoodTestingGeneratorPlugin : RedwoodGeneratorPlugin(Testing)
+
 @Suppress("unused") // Invoked reflectively by Gradle.
 public class RedwoodWidgetGeneratorPlugin : RedwoodGeneratorPlugin(Widget)
 
@@ -51,6 +55,7 @@ public abstract class RedwoodGeneratorPlugin(
     Compose("--compose", "redwood-compose"),
     ComposeProtocol("--compose-protocol", "redwood-protocol-compose"),
     LayoutModifiers("--layout-modifiers", "redwood-runtime"),
+    Testing("--testing", "redwood-compose-testing"),
     Widget("--widget", "redwood-widget"),
     WidgetProtocol("--widget-protocol", "redwood-protocol-widget"),
   }
diff --git a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/codegen.kt b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/codegen.kt
index 2c760d82e..fa212bdd7 100644
--- a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/codegen.kt
+++ b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/codegen.kt
@@ -17,6 +17,7 @@ package app.cash.redwood.tooling.codegen
 
 import app.cash.redwood.tooling.codegen.CodegenType.Compose
 import app.cash.redwood.tooling.codegen.CodegenType.LayoutModifiers
+import app.cash.redwood.tooling.codegen.CodegenType.Testing
 import app.cash.redwood.tooling.codegen.CodegenType.Widget
 import app.cash.redwood.tooling.schema.Schema
 import java.nio.file.Path
@@ -24,6 +25,7 @@ import java.nio.file.Path
 public enum class CodegenType {
   Compose,
   LayoutModifiers,
+  Testing,
   Widget,
 }
 
@@ -43,6 +45,14 @@ public fun Schema.generate(type: CodegenType, destination: Path) {
         generateLayoutModifierInterface(this, layoutModifier).writeTo(destination)
       }
     }
+    Testing -> {
+      generateTester(this).writeTo(destination)
+      generateMutableWidgetFactory(this).writeTo(destination)
+      for (widget in widgets) {
+        generateMutableWidget(this, widget).writeTo(destination)
+        generateWidgetValue(this, widget).writeTo(destination)
+      }
+    }
     Widget -> {
       generateWidgetFactories(this).writeTo(destination)
       generateWidgetFactory(this).writeTo(destination)
diff --git a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/composeGeneration.kt b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/composeGeneration.kt
index ded6e01ec..756dfda64 100644
--- a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/composeGeneration.kt
+++ b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/composeGeneration.kt
@@ -22,7 +22,6 @@ import app.cash.redwood.tooling.schema.Widget
 import app.cash.redwood.tooling.schema.Widget.Children
 import app.cash.redwood.tooling.schema.Widget.Event
 import app.cash.redwood.tooling.schema.Widget.Property
-import com.squareup.kotlinpoet.AnnotationSpec
 import com.squareup.kotlinpoet.ClassName
 import com.squareup.kotlinpoet.CodeBlock
 import com.squareup.kotlinpoet.FileSpec
@@ -75,11 +74,7 @@ internal fun generateComposable(
       FunSpec.builder(flatName)
         .addModifiers(PUBLIC)
         .addAnnotation(ComposeRuntime.Composable)
-        .addAnnotation(
-          AnnotationSpec.builder(Stdlib.OptIn)
-            .addMember("%T::class", Redwood.RedwoodCodegenApi)
-            .build(),
-        )
+        .addAnnotation(Redwood.OptInToRedwoodCodegenApi)
         .apply {
           // Set the layout modifier as the last non-child lambda in the function signature.
           // This ensures you can still use trailing lambda syntax.
diff --git a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/sharedHelpers.kt b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/sharedHelpers.kt
index 158259395..1a38754c6 100644
--- a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/sharedHelpers.kt
+++ b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/sharedHelpers.kt
@@ -92,6 +92,18 @@ internal fun Schema.getWidgetFactoryType(): ClassName {
   return ClassName(widgetPackage(this), "${name}WidgetFactory")
 }
 
+internal fun Schema.getMutableWidgetFactoryType(): ClassName {
+  return ClassName(widgetPackage(this), "Mutable${name}WidgetFactory")
+}
+
+internal fun Schema.mutableWidgetType(widget: Widget): ClassName {
+  return ClassName(widgetPackage(this), "Mutable${widget.type.flatName}")
+}
+
+internal fun Schema.widgetValueType(widget: Widget): ClassName {
+  return ClassName(widgetPackage(this), "${widget.type.flatName}Value")
+}
+
 internal fun Schema.getWidgetFactoryProviderType(): ClassName {
   return ClassName(widgetPackage(this), "${name}WidgetFactoryProvider")
 }
@@ -120,6 +132,10 @@ internal fun Schema.layoutModifierImpl(layoutModifier: LayoutModifier): ClassNam
   return ClassName(composePackage(), layoutModifier.type.simpleName!! + "Impl")
 }
 
+internal fun Schema.getTesterFunction(): MemberName {
+  return MemberName(widgetPackage(this), "${name}Tester")
+}
+
 internal val Schema.toLayoutModifier: MemberName get() =
   MemberName(widgetPackage(this), "toLayoutModifier")
 
diff --git a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/types.kt b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/types.kt
index a8ff8e300..c554d6867 100644
--- a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/types.kt
+++ b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/types.kt
@@ -61,6 +61,15 @@ internal object Redwood {
   val LayoutModifierElement = LayoutModifier.nestedClass("Element")
   val LayoutScopeMarker = ClassName("app.cash.redwood", "LayoutScopeMarker")
   val RedwoodCodegenApi = ClassName("app.cash.redwood", "RedwoodCodegenApi")
+  val OptInToRedwoodCodegenApi = AnnotationSpec.builder(Stdlib.OptIn)
+    .addMember("%T::class", RedwoodCodegenApi)
+    .build()
+}
+
+internal object RedwoodTesting {
+  val MutableWidget = ClassName("app.cash.redwood.compose.testing", "MutableWidget")
+  val RedwoodTester = ClassName("app.cash.redwood.compose.testing", "RedwoodTester")
+  val WidgetValue = ClassName("app.cash.redwood.compose.testing", "WidgetValue")
 }
 
 internal object RedwoodWidget {
@@ -68,6 +77,7 @@ internal object RedwoodWidget {
   val WidgetChildren = Widget.nestedClass("Children")
   val WidgetChildrenOfW = WidgetChildren.parameterizedBy(typeVariableW)
   val WidgetProvider = Widget.nestedClass("Provider")
+  val MutableListChildren = ClassName("app.cash.redwood.widget", "MutableListChildren")
 }
 
 internal object RedwoodCompose {
@@ -94,8 +104,10 @@ internal fun composableLambda(
 
 internal object Stdlib {
   val AssertionError = ClassName("kotlin", "AssertionError")
+  val List = ClassName("kotlin.collections", "List")
   val OptIn = ClassName("kotlin", "OptIn")
   val buildList = MemberName("kotlin.collections", "buildList")
+  val listOf = MemberName("kotlin.collections", "listOf")
 }
 
 internal val typeVariableW = TypeVariableName("W", listOf(ANY))
@@ -122,3 +134,7 @@ internal object KotlinxSerialization {
 
   @JvmField val jsonPrimitive = MemberName("kotlinx.serialization.json", "jsonPrimitive")
 }
+
+internal object KotlinxCoroutines {
+  val CoroutineScope = ClassName("kotlinx.coroutines", "CoroutineScope")
+}
diff --git a/samples/emoji-search/presenter/build.gradle b/samples/emoji-search/presenter/build.gradle
index 0abefc52b..03c56790b 100644
--- a/samples/emoji-search/presenter/build.gradle
+++ b/samples/emoji-search/presenter/build.gradle
@@ -17,6 +17,7 @@ kotlin {
     commonTest {
       dependencies {
         api projects.redwoodComposeTesting
+        api projects.samples.emojiSearch.schema.testing
         implementation libs.kotlin.test
         implementation libs.kotlinx.coroutines.test
       }
diff --git a/settings.gradle b/settings.gradle
index 8dc52be91..c6ed00ec4 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -12,6 +12,7 @@ include ':redwood-layout-dom'
 include ':redwood-layout-layoutmodifiers'
 include ':redwood-layout-schema'
 include ':redwood-layout-shared-test'
+include ':redwood-layout-testing'
 include ':redwood-layout-uiview'
 include ':redwood-layout-view'
 include ':redwood-layout-widget'
@@ -29,6 +30,7 @@ include ':redwood-treehouse-lazylayout-api'
 include ':redwood-treehouse-lazylayout-compose'
 include ':redwood-treehouse-lazylayout-composeui'
 include ':redwood-treehouse-lazylayout-schema'
+include ':redwood-treehouse-lazylayout-testing'
 include ':redwood-treehouse-lazylayout-uiview'
 include ':redwood-treehouse-lazylayout-view'
 include ':redwood-treehouse-lazylayout-widget'
@@ -39,6 +41,7 @@ include ':test-schema'
 include ':test-schema:compose'
 include ':test-schema:compose:protocol'
 include ':test-schema:layout-modifiers'
+include ':test-schema:testing'
 include ':test-schema:widget'
 include ':test-schema:widget:protocol'
 
@@ -65,6 +68,7 @@ if (!hasProperty('redwoodNoSamples')) {
   include ':samples:emoji-search:schema'
   include ':samples:emoji-search:schema:compose'
   include ':samples:emoji-search:schema:compose:protocol'
+  include ':samples:emoji-search:schema:testing'
   include ':samples:emoji-search:schema:widget'
   include ':samples:emoji-search:schema:widget:protocol'
   include ':samples:emoji-search:values'


Test patch:
diff --git a/redwood-compose-testing/build.gradle b/redwood-compose-testing/build.gradle
index 0707788ff..2c76a6a6d 100644
--- a/redwood-compose-testing/build.gradle
+++ b/redwood-compose-testing/build.gradle
@@ -11,12 +11,16 @@ kotlin {
     commonMain {
       dependencies {
         api libs.jetbrains.compose.runtime
-        api libs.kotlin.test
         api libs.kotlinx.coroutines.core
         api projects.redwoodCompose
         api projects.redwoodRuntime
       }
     }
+    commonTest {
+      dependencies {
+        implementation libs.kotlin.test
+      }
+    }
   }
 }
 
diff --git a/redwood-compose-testing/src/commonMain/kotlin/app/cash/redwood/compose/testing/WidgetValue.kt b/redwood-compose-testing/src/commonMain/kotlin/app/cash/redwood/compose/testing/WidgetValue.kt
index b00f90623..38b82db81 100644
--- a/redwood-compose-testing/src/commonMain/kotlin/app/cash/redwood/compose/testing/WidgetValue.kt
+++ b/redwood-compose-testing/src/commonMain/kotlin/app/cash/redwood/compose/testing/WidgetValue.kt
@@ -15,10 +15,66 @@
  */
 package app.cash.redwood.compose.testing
 
+import app.cash.redwood.LayoutModifier
+
 /**
  * A widget that's implemented as a value class, appropriate for use in tests.
  *
  * Implementations of this interface may have lambda properties that trigger application behavior.
  * These lambda properties are **excluded** from [Any.equals], [Any.hashCode], and [Any.toString].
  */
-public interface WidgetValue
+public interface WidgetValue {
+  public val layoutModifiers: LayoutModifier
+
+  /** Returns all of the direct children of this widget, grouped by slot. */
+  public val childrenLists: List<List<WidgetValue>>
+    get() = listOf()
+}
+
+/**
+ * Returns a sequence that does a depth-first preorder traversal of the entire widget tree whose
+ * roots are this list. This is the same order elements occur in code.
+ *
+ * For example, given the following structure:
+ *
+ * ```
+ * Column {
+ *   Toolbar {
+ *     Icon(...)
+ *     Title(...)
+ *   }
+ *   Row {
+ *     Text(...)
+ *     Button(...)
+ *   }
+ * }
+ * ```
+ *
+ * The flattened elements are returned in this order:
+ *
+ * ```
+ * Column,
+ * Toolbar,
+ * Icon,
+ * Title,
+ * Row,
+ * Text,
+ * Button
+ * ```
+ */
+public fun List<WidgetValue>.flatten(): Sequence<WidgetValue> {
+  return sequence {
+    for (widget in this@flatten) {
+      flattenRecursive(widget)
+    }
+  }
+}
+
+private suspend fun SequenceScope<WidgetValue>.flattenRecursive(widgetValue: WidgetValue) {
+  yield(widgetValue)
+  for (children in widgetValue.childrenLists) {
+    for (child in children) {
+      flattenRecursive(child)
+    }
+  }
+}
diff --git a/redwood-compose-testing/src/commonTest/kotlin/app/cash/redwood/compose/testing/WidgetValueTest.kt b/redwood-compose-testing/src/commonTest/kotlin/app/cash/redwood/compose/testing/WidgetValueTest.kt
new file mode 100644
index 000000000..dacc75b68
--- /dev/null
+++ b/redwood-compose-testing/src/commonTest/kotlin/app/cash/redwood/compose/testing/WidgetValueTest.kt
@@ -0,0 +1,80 @@
+/*
+ * Copyright (C) 2023 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package app.cash.redwood.compose.testing
+
+import app.cash.redwood.LayoutModifier
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class WidgetValueTest {
+  @Test
+  fun flattenNoHierarchy() {
+    val a = SimpleWidgetValue()
+    val b = SimpleWidgetValue()
+    val c = SimpleWidgetValue()
+
+    assertEquals(
+      listOf(),
+      listOf<WidgetValue>().flatten().toList(),
+    )
+    assertEquals(
+      listOf(a),
+      listOf<WidgetValue>(a).flatten().toList(),
+    )
+    assertEquals(
+      listOf(a, b, c),
+      listOf<WidgetValue>(a, b, c).flatten().toList(),
+    )
+  }
+
+  @Test
+  fun flattenParentsFirst() {
+    val a = SimpleWidgetValue()
+    val aa = SimpleWidgetValue(childrenLists = listOf(listOf(a)))
+    val aaa = SimpleWidgetValue(childrenLists = listOf(listOf(aa)))
+
+    assertEquals(
+      listOf(aa, a),
+      listOf<WidgetValue>(aa).flatten().toList(),
+    )
+    assertEquals(
+      listOf(aaa, aa, a),
+      listOf<WidgetValue>(aaa).flatten().toList(),
+    )
+  }
+
+  @Test
+  fun flattenSiblingSubtreeFirst() {
+    val a = SimpleWidgetValue()
+    val aa = SimpleWidgetValue(childrenLists = listOf(listOf(a)))
+    val aaa = SimpleWidgetValue(childrenLists = listOf(listOf(aa)))
+    val b = SimpleWidgetValue()
+
+    assertEquals(
+      listOf(aa, a, b),
+      listOf<WidgetValue>(aa, b).flatten().toList(),
+    )
+    assertEquals(
+      listOf(aaa, aa, a, b),
+      listOf<WidgetValue>(aaa, b).flatten().toList(),
+    )
+  }
+
+  class SimpleWidgetValue(
+    override val layoutModifiers: LayoutModifier = LayoutModifier,
+    override val childrenLists: List<List<WidgetValue>> = listOf(),
+  ) : WidgetValue
+}
diff --git a/redwood-layout-testing/build.gradle b/redwood-layout-testing/build.gradle
new file mode 100644
index 000000000..9d0152b36
--- /dev/null
+++ b/redwood-layout-testing/build.gradle
@@ -0,0 +1,22 @@
+apply plugin: 'org.jetbrains.kotlin.multiplatform'
+apply plugin: 'app.cash.redwood.generator.testing'
+apply plugin: 'com.vanniktech.maven.publish'
+apply plugin: 'org.jetbrains.dokka' // Must be applied here for publish plugin.
+
+kotlin {
+  apply from: "${rootDir}/addAllTargets.gradle"
+
+  sourceSets {
+    commonMain {
+      dependencies {
+        api projects.redwoodLayoutLayoutmodifiers
+        api projects.redwoodLayoutWidget
+      }
+    }
+  }
+}
+
+redwoodSchema {
+  source = projects.redwoodLayoutSchema
+  type = 'app.cash.redwood.layout.RedwoodLayout'
+}
diff --git a/redwood-layout-testing/gradle.properties b/redwood-layout-testing/gradle.properties
new file mode 100644
index 000000000..2db4b5945
--- /dev/null
+++ b/redwood-layout-testing/gradle.properties
@@ -0,0 +1,2 @@
+POM_ARTIFACT_ID=redwood-layout-testing
+POM_NAME=Redwood Layout Testing
diff --git a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/testingGeneration.kt b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/testingGeneration.kt
new file mode 100644
index 000000000..9ba43814b
--- /dev/null
+++ b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/testingGeneration.kt
@@ -0,0 +1,378 @@
+/*
+ * Copyright (C) 2023 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package app.cash.redwood.tooling.codegen
+
+import app.cash.redwood.tooling.schema.ProtocolWidget.ProtocolTrait
+import app.cash.redwood.tooling.schema.Schema
+import app.cash.redwood.tooling.schema.Widget
+import app.cash.redwood.tooling.schema.Widget.Children
+import app.cash.redwood.tooling.schema.Widget.Event
+import app.cash.redwood.tooling.schema.Widget.Property
+import com.squareup.kotlinpoet.ANY
+import com.squareup.kotlinpoet.BOOLEAN
+import com.squareup.kotlinpoet.CodeBlock
+import com.squareup.kotlinpoet.FileSpec
+import com.squareup.kotlinpoet.FunSpec
+import com.squareup.kotlinpoet.KModifier.INTERNAL
+import com.squareup.kotlinpoet.KModifier.LATEINIT
+import com.squareup.kotlinpoet.KModifier.OVERRIDE
+import com.squareup.kotlinpoet.KModifier.PRIVATE
+import com.squareup.kotlinpoet.KModifier.PUBLIC
+import com.squareup.kotlinpoet.LIST
+import com.squareup.kotlinpoet.ParameterSpec
+import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
+import com.squareup.kotlinpoet.PropertySpec
+import com.squareup.kotlinpoet.TypeSpec
+import com.squareup.kotlinpoet.asTypeName
+
+/*
+@OptIn(RedwoodCodegenApi::class)
+public fun SunspotTester(scope: CoroutineScope): RedwoodTester {
+  return RedwoodTester(
+    scope = scope,
+    provider = SunspotWidgetFactories(
+      Sunspot = MutableSunspotWidgetFactory(),
+      RedwoodLayout = MutableRedwoodLayoutWidgetFactory(),
+    ),
+  )
+}
+*/
+internal fun generateTester(schema: Schema): FileSpec {
+  val testerFunction = schema.getTesterFunction()
+  return FileSpec.builder(testerFunction.packageName, testerFunction.simpleName)
+    .addFunction(
+      FunSpec.builder(testerFunction.simpleName)
+        .addAnnotation(Redwood.OptInToRedwoodCodegenApi)
+        .addParameter("scope", KotlinxCoroutines.CoroutineScope)
+        .returns(RedwoodTesting.RedwoodTester)
+        .addCode("return %T(⇥\n", RedwoodTesting.RedwoodTester)
+        .addCode("scope = scope,\n")
+        .addCode("provider = %T(⇥\n", schema.getWidgetFactoriesType())
+        .apply {
+          for (dependency in schema.allSchemas) {
+            addCode("%N = %T(),\n", dependency.name, dependency.getMutableWidgetFactoryType())
+          }
+        }
+        .addCode("⇤),\n")
+        .addCode("⇤)\n", RedwoodTesting.RedwoodTester)
+        .build(),
+    )
+    .build()
+}
+
+/*
+@RedwoodCodegenApi
+public class MutableSunspotWidgetFactory : SunspotWidgetFactory<MutableWidget> {
+  public override fun SunspotText(): SunspotText<MutableWidget> = MutableSunspotText()
+
+  public override fun SunspotButton(): SunspotButton<MutableWidget> = MutableSunspotButton()
+}
+*/
+internal fun generateMutableWidgetFactory(schema: Schema): FileSpec {
+  val mutableWidgetFactoryType = schema.getMutableWidgetFactoryType()
+  return FileSpec.builder(mutableWidgetFactoryType.packageName, mutableWidgetFactoryType.simpleName)
+    .addType(
+      TypeSpec.classBuilder(mutableWidgetFactoryType)
+        .addSuperinterface(schema.getWidgetFactoryType().parameterizedBy(RedwoodTesting.MutableWidget))
+        .addAnnotation(Redwood.RedwoodCodegenApi)
+        .apply {
+          for (widget in schema.widgets) {
+            addFunction(
+              FunSpec.builder(widget.type.flatName)
+                .addModifiers(PUBLIC, OVERRIDE)
+                .returns(schema.widgetType(widget).parameterizedBy(RedwoodTesting.MutableWidget))
+                .addCode("return %T()", schema.mutableWidgetType(widget))
+                .build(),
+            )
+          }
+        }
+        .build(),
+    )
+    .build()
+}
+
+/*
+@RedwoodCodegenApi
+internal class MutableSunspotButton : SunspotButton<MutableWidget>, MutableWidget {
+  public override val value: MutableWidget
+    get() = this
+
+  public override var layoutModifiers: LayoutModifier = LayoutModifier
+
+  private var text: String? = null
+  private var enabled: Boolean = false
+
+  public override fun text(text: String?) {
+    this.text = text
+  }
+
+  public override fun enabled(enabled: Boolean) {
+    this.enabled = enabled
+  }
+
+  public override fun snapshot() : SunspotButtonValue {
+    return SunspotButtonValue(layoutModifiers, text, enabled)
+  }
+}
+*/
+internal fun generateMutableWidget(schema: Schema, widget: Widget): FileSpec {
+  val mutableWidgetType = schema.mutableWidgetType(widget)
+  val widgetValueType = schema.widgetValueType(widget)
+  return FileSpec.builder(mutableWidgetType.packageName, mutableWidgetType.simpleName)
+    .addType(
+      TypeSpec.classBuilder(mutableWidgetType)
+        .addModifiers(INTERNAL)
+        .addSuperinterface(schema.widgetType(widget).parameterizedBy(RedwoodTesting.MutableWidget))
+        .addSuperinterface(RedwoodTesting.MutableWidget)
+        .addAnnotation(Redwood.RedwoodCodegenApi)
+        .addProperty(
+          PropertySpec.builder("value", RedwoodTesting.MutableWidget)
+            .addModifiers(PUBLIC, OVERRIDE)
+            .getter(
+              FunSpec.getterBuilder()
+                .addCode("return this")
+                .build(),
+            )
+            .build(),
+        )
+        .addProperty(
+          PropertySpec.builder("layoutModifiers", Redwood.LayoutModifier)
+            .addModifiers(PUBLIC, OVERRIDE)
+            .mutable(true)
+            .initializer("%T", Redwood.LayoutModifier)
+            .build(),
+        )
+        .apply {
+          for (trait in widget.traits) {
+            when (trait) {
+              is Property, is Event -> {
+                val type = when (trait) {
+                  is Property -> trait.type.asTypeName()
+                  is Event -> trait.lambdaType
+                  else -> throw AssertionError()
+                }
+                addProperty(
+                  PropertySpec.builder(trait.name, type)
+                    .addModifiers(PRIVATE)
+                    .apply {
+                      if (trait.defaultExpression != null) {
+                        initializer(trait.defaultExpression!!)
+                      } else if (type.isNullable) {
+                        initializer("null")
+                      } else {
+                        addModifiers(LATEINIT)
+                      }
+                    }
+                    .mutable(true)
+                    .build(),
+                )
+                addFunction(
+                  FunSpec.builder(trait.name)
+                    .addModifiers(PUBLIC, OVERRIDE)
+                    .addParameter(trait.name, type)
+                    .addCode("this.%N = %N", trait.name, trait.name)
+                    .build(),
+                )
+              }
+              is Children -> {
+                val mutableChildrenOfMutableWidget = RedwoodWidget.MutableListChildren
+                  .parameterizedBy(RedwoodTesting.MutableWidget)
+                addProperty(
+                  PropertySpec.builder(trait.name, mutableChildrenOfMutableWidget)
+                    .addModifiers(PUBLIC, OVERRIDE)
+                    .initializer("%T()", mutableChildrenOfMutableWidget)
+                    .build(),
+                )
+              }
+              is ProtocolTrait -> throw AssertionError()
+            }
+          }
+        }
+        .addFunction(
+          FunSpec.builder("snapshot")
+            .addModifiers(PUBLIC, OVERRIDE)
+            .returns(widgetValueType)
+            .addCode("return %T(⇥\n", widgetValueType)
+            .addCode("layoutModifiers = layoutModifiers,\n")
+            .apply {
+              for (trait in widget.traits) {
+                when (trait) {
+                  is Property, is Event -> addCode("%N = %N,\n", trait.name, trait.name)
+                  is Children -> addCode("%N = %N.map { it.`value`.snapshot() },\n", trait.name, trait.name)
+                  is ProtocolTrait -> throw AssertionError()
+                }
+              }
+            }
+            .addCode("⇤)\n")
+            .build(),
+        )
+        .build(),
+    )
+    .build()
+}
+
+/*
+public class SunspotButtonValue(
+  public override val layoutModifiers: LayoutModifier = LayoutModifier,
+  public val text: String? = null,
+  public val enabled: Boolean = false,
+) : WidgetValue {
+  public override val childrenLists: List<List<WidgetValue>>
+    get() = listOf()
+
+  public override fun equals(other: Any?): Boolean = other is SunspotButtonValue &&
+    other.text == text &&
+    other.enabled == enabled
+
+  public override fun hashCode(): Int = listOf(
+    text,
+    enabled,
+  ).hashCode()
+
+  public override fun toString(): String =
+    """SunspotButtonValue(text=$text, enabled=$enabled)"""
+}
+*/
+internal fun generateWidgetValue(schema: Schema, widget: Widget): FileSpec {
+  val widgetValueType = schema.widgetValueType(widget)
+
+  val classBuilder = TypeSpec.classBuilder(widgetValueType)
+    .addModifiers(PUBLIC)
+    .addSuperinterface(RedwoodTesting.WidgetValue)
+    .addProperty(
+      PropertySpec.builder("layoutModifiers", Redwood.LayoutModifier)
+        .addModifiers(PUBLIC, OVERRIDE)
+        .initializer("layoutModifiers")
+        .build(),
+    )
+
+  val constructorBuilder = FunSpec.constructorBuilder()
+    .addParameter(
+      ParameterSpec.builder("layoutModifiers", Redwood.LayoutModifier)
+        .defaultValue("%T", Redwood.LayoutModifier)
+        .build(),
+    )
+
+  val childrenListsBuilder = CodeBlock.builder()
+    .add("return %M(⇥\n", Stdlib.listOf)
+
+  val equalsBuilder = CodeBlock.builder()
+    .add("return other is %T·&&⇥\n", widgetValueType)
+    .add("other.layoutModifiers == layoutModifiers", widgetValueType)
+  val hashCodeBuilder = CodeBlock.builder()
+    .add("return %M(⇥\n", Stdlib.listOf)
+    .add("layoutModifiers,\n")
+  val toStringBuilder = StringBuilder()
+    .append("${widgetValueType.simpleName}(layoutModifiers=${'$'}layoutModifiers")
+
+  for (trait in widget.traits) {
+    val type = when (trait) {
+      is Property -> trait.type.asTypeName()
+      is Event -> trait.lambdaType
+      is Children -> Stdlib.List.parameterizedBy(RedwoodTesting.WidgetValue)
+      else -> throw AssertionError()
+    }
+
+    val defaultExpression = when (trait) {
+      is Property -> trait.defaultExpression?.let { CodeBlock.of(it) }
+      is Event -> trait.defaultExpression?.let { CodeBlock.of(it) }
+      is Children -> CodeBlock.of("%M()", Stdlib.listOf)
+      else -> throw AssertionError()
+    }
+
+    constructorBuilder.addParameter(
+      ParameterSpec.builder(trait.name, type)
+        .defaultValue(defaultExpression)
+        .build(),
+    )
+
+    classBuilder.addProperty(
+      PropertySpec.builder(trait.name, type)
+        .initializer("%N", trait.name)
+        .build(),
+    )
+
+    when (trait) {
+      is Property, is Children -> {
+        if (trait is Children) {
+          childrenListsBuilder.add("%N,\n", trait.name)
+        }
+
+        equalsBuilder.add("·&&\n")
+        equalsBuilder.add("other.%N == %N", trait.name, trait.name)
+
+        hashCodeBuilder.add("%N,\n", trait.name)
+
+        toStringBuilder.append(", ${trait.name}=${'$'}${trait.name}")
+      }
+
+      is Event -> Unit // Events are omitted from equals/hashCode/toString.
+
+      else -> throw AssertionError()
+    }
+  }
+
+  childrenListsBuilder.add("⇤)\n")
+
+  equalsBuilder.add("⇤\n")
+
+  hashCodeBuilder.add("⇤).hashCode()\n")
+
+  toStringBuilder.append(")")
+
+  return FileSpec.builder(widgetValueType.packageName, widgetValueType.simpleName)
+    .addType(
+      classBuilder
+        .primaryConstructor(constructorBuilder.build())
+        .addProperty(
+          PropertySpec.builder(
+            "childrenLists",
+            LIST.parameterizedBy(LIST.parameterizedBy(RedwoodTesting.WidgetValue)),
+          )
+            .addModifiers(PUBLIC, OVERRIDE)
+            .getter(
+              FunSpec.getterBuilder()
+                .addCode(childrenListsBuilder.build())
+                .build(),
+            )
+            .build(),
+        )
+        .addFunction(
+          FunSpec.builder("equals")
+            .addModifiers(PUBLIC, OVERRIDE)
+            .addParameter("other", ANY.copy(nullable = true))
+            .returns(BOOLEAN)
+            .addCode(equalsBuilder.build())
+            .build(),
+        )
+        .addFunction(
+          FunSpec.builder("hashCode")
+            .addModifiers(PUBLIC, OVERRIDE)
+            .returns(Int::class)
+            .addCode(hashCodeBuilder.build())
+            .build(),
+        )
+        .addFunction(
+          FunSpec.builder("toString")
+            .addModifiers(PUBLIC, OVERRIDE)
+            .returns(String::class)
+            .addCode("return %P", toStringBuilder.toString())
+            .build(),
+        )
+        .build(),
+    )
+    .build()
+}
diff --git a/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/TestingGenerationTest.kt b/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/TestingGenerationTest.kt
new file mode 100644
index 000000000..22a383357
--- /dev/null
+++ b/redwood-tooling-codegen/src/test/kotlin/app/cash/redwood/tooling/codegen/TestingGenerationTest.kt
@@ -0,0 +1,144 @@
+/*
+ * Copyright (C) 2023 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package app.cash.redwood.tooling.codegen
+
+import app.cash.redwood.schema.Children
+import app.cash.redwood.schema.Default
+import app.cash.redwood.schema.Property
+import app.cash.redwood.schema.Schema
+import app.cash.redwood.schema.Widget
+import app.cash.redwood.tooling.schema.parseSchema
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class TestingGenerationTest {
+  @Schema(
+    [
+      BasicWidget::class,
+    ],
+  )
+  interface HappyPathSchema
+
+  @Widget(1)
+  data class BasicWidget(
+    @Property(1)
+    @Default("\"test\"")
+    val trait: String,
+    @Property(2)
+    @Default("{ error(\"test\") }")
+    val onEvent: () -> Unit,
+    @Children(1)
+    @Default("{}")
+    val block: () -> Unit,
+  )
+
+  @Test fun `tester happy path`() {
+    val schema = parseSchema(HappyPathSchema::class)
+
+    val testerFileSpec = generateTester(schema)
+    assertThat(testerFileSpec.toString()).contains(
+      """
+      |@OptIn(RedwoodCodegenApi::class)
+      |public fun HappyPathSchemaTester(scope: CoroutineScope): RedwoodTester = RedwoodTester(
+      |  scope = scope,
+      |  provider = HappyPathSchemaWidgetFactories(
+      |    HappyPathSchema = MutableHappyPathSchemaWidgetFactory(),
+      |  ),
+      |)
+      """.trimMargin(),
+    )
+
+    val mutableWidgetFactorySpec = generateMutableWidgetFactory(schema)
+    assertThat(mutableWidgetFactorySpec.toString()).contains(
+      """
+      |@RedwoodCodegenApi
+      |public class MutableHappyPathSchemaWidgetFactory : HappyPathSchemaWidgetFactory<MutableWidget> {
+      |  public override fun TestingGenerationTestBasicWidget():
+      |      TestingGenerationTestBasicWidget<MutableWidget> = MutableTestingGenerationTestBasicWidget()
+      |}
+      """.trimMargin(),
+    )
+
+    val mutableWidgetSpec = generateMutableWidget(schema, schema.widgets.single())
+    assertThat(mutableWidgetSpec.toString()).contains(
+      """
+      |@RedwoodCodegenApi
+      |internal class MutableTestingGenerationTestBasicWidget :
+      |    TestingGenerationTestBasicWidget<MutableWidget>, MutableWidget {
+      |  public override val `value`: MutableWidget
+      |    get() = this
+      |
+      |  public override var layoutModifiers: LayoutModifier = LayoutModifier
+      |
+      |  private var trait: String = "test"
+      |
+      |  private var onEvent: (() -> Unit)? = { error("test") }
+      |
+      |  public override val block: MutableListChildren<MutableWidget> =
+      |      MutableListChildren<MutableWidget>()
+      |
+      |  public override fun trait(trait: String): Unit {
+      |    this.trait = trait
+      |  }
+      |
+      |  public override fun onEvent(onEvent: (() -> Unit)?): Unit {
+      |    this.onEvent = onEvent
+      |  }
+      |
+      |  public override fun snapshot(): TestingGenerationTestBasicWidgetValue =
+      |      TestingGenerationTestBasicWidgetValue(
+      |    layoutModifiers = layoutModifiers,
+      |    trait = trait,
+      |    onEvent = onEvent,
+      |    block = block.map { it.`value`.snapshot() },
+      |  )
+      |}
+      """.trimMargin(),
+    )
+
+    val widgetValueSpec = generateWidgetValue(schema, schema.widgets.single())
+    assertThat(widgetValueSpec.toString()).contains(
+      """
+      |public class TestingGenerationTestBasicWidgetValue(
+      |  public override val layoutModifiers: LayoutModifier = LayoutModifier,
+      |  public val trait: String = "test",
+      |  public val onEvent: (() -> Unit)? = { error("test") },
+      |  public val block: List<WidgetValue> = listOf(),
+      |) : WidgetValue {
+      |  public override val childrenLists: List<List<WidgetValue>>
+      |    get() = listOf(
+      |      block,
+      |    )
+      |
+      |  public override fun equals(other: Any?): Boolean = other is
+      |      TestingGenerationTestBasicWidgetValue &&
+      |    other.layoutModifiers == layoutModifiers &&
+      |    other.trait == trait &&
+      |    other.block == block
+      |
+      |  public override fun hashCode(): Int = listOf(
+      |    layoutModifiers,
+      |    trait,
+      |    block,
+      |  ).hashCode()
+      |
+      |  public override fun toString(): String =
+      |      "${'"'}"TestingGenerationTestBasicWidgetValue(layoutModifiers=${'$'}layoutModifiers, trait=${'$'}trait, block=${'$'}block)"${'"'}"
+      |}
+      """.trimMargin(),
+    )
+  }
+}
diff --git a/redwood-treehouse-lazylayout-testing/build.gradle b/redwood-treehouse-lazylayout-testing/build.gradle
new file mode 100644
index 000000000..2e8f4a8bb
--- /dev/null
+++ b/redwood-treehouse-lazylayout-testing/build.gradle
@@ -0,0 +1,22 @@
+apply plugin: 'org.jetbrains.kotlin.multiplatform'
+apply plugin: 'app.cash.redwood.generator.testing'
+apply plugin: 'com.vanniktech.maven.publish'
+apply plugin: 'org.jetbrains.dokka' // Must be applied here for publish plugin.
+
+kotlin {
+  apply from: "${rootDir}/addAllTargets.gradle"
+
+  sourceSets {
+    commonMain {
+      dependencies {
+        api projects.redwoodTreehouseLazylayoutApi
+        api projects.redwoodTreehouseLazylayoutWidget
+      }
+    }
+  }
+}
+
+redwoodSchema {
+  source = projects.redwoodTreehouseLazylayoutSchema
+  type = 'app.cash.redwood.treehouse.lazylayout.RedwoodTreehouseLazyLayout'
+}
diff --git a/redwood-treehouse-lazylayout-testing/gradle.properties b/redwood-treehouse-lazylayout-testing/gradle.properties
new file mode 100644
index 000000000..4764b6ff5
--- /dev/null
+++ b/redwood-treehouse-lazylayout-testing/gradle.properties
@@ -0,0 +1,2 @@
+POM_ARTIFACT_ID=redwood-treehouse-lazylayout-testing
+POM_NAME=Redwood Treehouse Lazy Layout Testing
diff --git a/samples/emoji-search/presenter/src/commonTest/kotlin/com/example/redwood/emojisearch/presenter/EmojiSearchTest.kt b/samples/emoji-search/presenter/src/commonTest/kotlin/com/example/redwood/emojisearch/presenter/EmojiSearchTest.kt
index 8ae00b64f..8f3b34003 100644
--- a/samples/emoji-search/presenter/src/commonTest/kotlin/com/example/redwood/emojisearch/presenter/EmojiSearchTest.kt
+++ b/samples/emoji-search/presenter/src/commonTest/kotlin/com/example/redwood/emojisearch/presenter/EmojiSearchTest.kt
@@ -20,8 +20,14 @@ import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
+import app.cash.redwood.compose.testing.flatten
+import app.cash.redwood.layout.compose.Column
+import app.cash.redwood.layout.widget.ColumnValue
 import com.example.redwood.emojisearch.compose.Text
 import com.example.redwood.emojisearch.compose.TextInput
+import com.example.redwood.emojisearch.widget.EmojiSearchTester
+import com.example.redwood.emojisearch.widget.TextInputValue
+import com.example.redwood.emojisearch.widget.TextValue
 import example.values.TextFieldState
 import kotlin.test.Test
 import kotlin.test.assertEquals
@@ -43,28 +49,37 @@ class EmojiSearchTest {
       val snapshot0 = awaitSnapshot()
       assertEquals(
         listOf(
-          TextInputValue(
-            state = TextFieldState(text = ""),
-            hint = "Search",
+          ColumnValue(
+            children = listOf(
+              TextInputValue(
+                state = TextFieldState(text = ""),
+                hint = "Search",
+              ),
+            ),
           ),
         ),
         snapshot0,
       )
 
-      (snapshot0.single() as TextInputValue).onChange!!.invoke(TextFieldState(text = "tree"))
+      snapshot0.flatten().filterIsInstance<TextInputValue>().first()
+        .onChange!!.invoke(TextFieldState(text = "tree"))
 
       val snapshot1 = awaitSnapshot()
       assertEquals(
         listOf(
-          TextInputValue(
-            state = TextFieldState(text = "tree"),
-            hint = "Search",
-          ),
-          TextValue(
-            text = "🌲",
-          ),
-          TextValue(
-            text = "🌳",
+          ColumnValue(
+            children = listOf(
+              TextInputValue(
+                state = TextFieldState(text = "tree"),
+                hint = "Search",
+              ),
+              TextValue(
+                text = "🌲",
+              ),
+              TextValue(
+                text = "🌳",
+              ),
+            ),
           ),
         ),
         snapshot1,
@@ -76,17 +91,19 @@ class EmojiSearchTest {
   @Composable
   private fun BasicEmojiSearch() {
     var search by remember { mutableStateOf(TextFieldState()) }
-    TextInput(
-      state = search,
-      hint = "Search",
-      onChange = {
-        search = it
-      },
-    )
+    Column {
+      TextInput(
+        state = search,
+        hint = "Search",
+        onChange = {
+          search = it
+        },
+      )
 
-    if ("tree" in search.text) {
-      Text(text = "🌲")
-      Text(text = "🌳")
+      if ("tree" in search.text) {
+        Text(text = "🌲")
+        Text(text = "🌳")
+      }
     }
   }
 }
diff --git a/samples/emoji-search/presenter/src/commonTest/kotlin/com/example/redwood/emojisearch/presenter/EmojiSearchWidgets.kt b/samples/emoji-search/presenter/src/commonTest/kotlin/com/example/redwood/emojisearch/presenter/EmojiSearchWidgets.kt
deleted file mode 100644
index 8df2cad0d..000000000
--- a/samples/emoji-search/presenter/src/commonTest/kotlin/com/example/redwood/emojisearch/presenter/EmojiSearchWidgets.kt
+++ /dev/null
@@ -1,189 +0,0 @@
-/*
- * Copyright (C) 2023 Square, Inc.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.example.redwood.emojisearch.presenter
-
-import app.cash.redwood.LayoutModifier
-import app.cash.redwood.RedwoodCodegenApi
-import app.cash.redwood.compose.testing.MutableWidget
-import app.cash.redwood.compose.testing.RedwoodTester
-import app.cash.redwood.compose.testing.WidgetValue
-import app.cash.redwood.layout.widget.Column
-import app.cash.redwood.layout.widget.RedwoodLayoutWidgetFactory
-import app.cash.redwood.layout.widget.Row
-import app.cash.redwood.treehouse.lazylayout.widget.LazyColumn
-import app.cash.redwood.treehouse.lazylayout.widget.RedwoodTreehouseLazyLayoutWidgetFactory
-import com.example.redwood.emojisearch.widget.EmojiSearchWidgetFactories
-import com.example.redwood.emojisearch.widget.EmojiSearchWidgetFactory
-import com.example.redwood.emojisearch.widget.Image
-import com.example.redwood.emojisearch.widget.Text
-import com.example.redwood.emojisearch.widget.TextInput
-import example.values.TextFieldState
-import kotlinx.coroutines.CoroutineScope
-
-// TODO(swankjesse): GENERATE THIS.
-
-@OptIn(RedwoodCodegenApi::class)
-fun EmojiSearchTester(scope: CoroutineScope): RedwoodTester {
-  return RedwoodTester(
-    scope = scope,
-    provider = EmojiSearchWidgetFactories(
-      EmojiSearch = MutableEmojiSearchWidgetFactory(),
-      RedwoodLayout = MutableRedwoodLayoutWidgetFactory(),
-      RedwoodTreehouseLazyLayout = MutableRedwoodTreehouseLazyLayoutWidgetFactory(),
-    ),
-  )
-}
-
-@RedwoodCodegenApi
-private class MutableEmojiSearchWidgetFactory : EmojiSearchWidgetFactory<MutableWidget> {
-  override fun TextInput(): TextInput<MutableWidget> = MutableTextInput()
-
-  override fun Text(): Text<MutableWidget> = MutableText()
-
-  override fun Image(): Image<MutableWidget> = MutableImage()
-}
-
-@RedwoodCodegenApi
-private class MutableTextInput : TextInput<MutableWidget>, MutableWidget {
-  override val value: MutableWidget
-    get() = this
-
-  override var layoutModifiers: LayoutModifier = LayoutModifier
-
-  var state: TextFieldState? = null
-  var hint: String? = null
-  var onChange: ((TextFieldState) -> Unit)? = null
-
-  override fun state(state: TextFieldState) {
-    this.state = state
-  }
-
-  override fun hint(hint: String) {
-    this.hint = hint
-  }
-
-  override fun onChange(onChange: ((TextFieldState) -> Unit)?) {
-    this.onChange = onChange
-  }
-
-  override fun snapshot() = TextInputValue(layoutModifiers, state, hint, onChange)
-}
-
-@RedwoodCodegenApi
-private class MutableText : Text<MutableWidget>, MutableWidget {
-  override val value: MutableWidget
-    get() = this
-
-  override var layoutModifiers: LayoutModifier = LayoutModifier
-
-  var text: String? = null
-
-  override fun text(text: String) {
-    this.text = text
-  }
-
-  override fun snapshot() = TextValue(layoutModifiers, text)
-}
-
-@RedwoodCodegenApi
-private class MutableImage : Image<MutableWidget>, MutableWidget {
-  override val value: MutableWidget
-    get() = this
-
-  override var layoutModifiers: LayoutModifier = LayoutModifier
-
-  var url: String? = null
-
-  override fun url(url: String) {
-    this.url = url
-  }
-
-  override fun snapshot() = ImageValue(layoutModifiers, url)
-}
-
-@RedwoodCodegenApi
-private class MutableRedwoodLayoutWidgetFactory : RedwoodLayoutWidgetFactory<MutableWidget> {
-  override fun Column(): Column<MutableWidget> = error("TODO")
-
-  override fun Row(): Row<MutableWidget> = error("TODO")
-}
-
-@RedwoodCodegenApi
-private class MutableRedwoodTreehouseLazyLayoutWidgetFactory :
-  RedwoodTreehouseLazyLayoutWidgetFactory<MutableWidget> {
-  override fun LazyColumn(): LazyColumn<MutableWidget> = error("TODO")
-}
-
-class TextInputValue(
-  val layoutModifiers: LayoutModifier = LayoutModifier,
-  val state: TextFieldState? = null,
-  val hint: String? = null,
-  /** Note lambdas are omitted from equals() and hashCode(). */
-  val onChange: ((TextFieldState) -> Unit)? = {},
-) : WidgetValue {
-  override fun equals(other: Any?): Boolean {
-    return other is TextInputValue &&
-      other.layoutModifiers == layoutModifiers &&
-      other.state == state &&
-      other.hint == hint
-  }
-
-  override fun hashCode(): Int {
-    return listOf(layoutModifiers, state, hint).hashCode()
-  }
-
-  override fun toString(): String {
-    return "TextValue(layoutModifiers=$layoutModifiers, state=$state, hint=$hint)"
-  }
-}
-
-class TextValue(
-  val layoutModifiers: LayoutModifier = LayoutModifier,
-  val text: String? = null,
-) : WidgetValue {
-  override fun equals(other: Any?): Boolean {
-    return other is TextValue &&
-      other.layoutModifiers == layoutModifiers &&
-      other.text == text
-  }
-
-  override fun hashCode(): Int {
-    return listOf(layoutModifiers, text).hashCode()
-  }
-
-  override fun toString(): String {
-    return "TextValue(layoutModifiers=$layoutModifiers, text=$text)"
-  }
-}
-
-class ImageValue(
-  val layoutModifiers: LayoutModifier = LayoutModifier,
-  val url: String? = null,
-) : WidgetValue {
-  override fun equals(other: Any?): Boolean {
-    return other is ImageValue &&
-      other.layoutModifiers == layoutModifiers &&
-      other.url == url
-  }
-
-  override fun hashCode(): Int {
-    return listOf(layoutModifiers, url).hashCode()
-  }
-
-  override fun toString(): String {
-    return "ImageValue(layoutModifiers=$layoutModifiers, url=$url)"
-  }
-}
diff --git a/samples/emoji-search/schema/testing/build.gradle b/samples/emoji-search/schema/testing/build.gradle
new file mode 100644
index 000000000..17310e6c9
--- /dev/null
+++ b/samples/emoji-search/schema/testing/build.gradle
@@ -0,0 +1,34 @@
+apply plugin: 'org.jetbrains.kotlin.multiplatform'
+apply plugin: 'app.cash.redwood.generator.testing'
+
+archivesBaseName = 'schema-testing'
+
+kotlin {
+  iosArm64()
+  iosX64()
+  iosSimulatorArm64()
+
+  js {
+    browser()
+  }
+
+  jvm()
+
+  sourceSets {
+    commonMain {
+      dependencies {
+        api projects.redwoodLayoutWidget
+        api projects.redwoodLayoutTesting
+        api projects.redwoodTreehouseLazylayoutWidget
+        api projects.redwoodTreehouseLazylayoutTesting
+        api projects.samples.emojiSearch.values
+        api projects.samples.emojiSearch.schema.widget
+      }
+    }
+  }
+}
+
+redwoodSchema {
+  source = projects.samples.emojiSearch.schema
+  type = 'com.example.redwood.emojisearch.EmojiSearch'
+}
diff --git a/test-schema/testing/build.gradle b/test-schema/testing/build.gradle
new file mode 100644
index 000000000..9c7b95164
--- /dev/null
+++ b/test-schema/testing/build.gradle
@@ -0,0 +1,39 @@
+apply plugin: 'org.jetbrains.kotlin.multiplatform'
+apply plugin: 'app.cash.redwood.generator.testing'
+
+kotlin {
+  apply from: "${rootDir}/addAllTargets.gradle"
+
+  sourceSets {
+    commonMain {
+      dependencies {
+        api projects.redwoodLayoutWidget
+        api projects.redwoodLayoutTesting
+        api projects.testSchema.widget
+      }
+    }
+  }
+
+  // Ensure our codegen correctly works in explicit API mode.
+  explicitApi()
+
+  targets.all {
+    compilations.all {
+      kotlinOptions {
+        // Ensure our codegen does not produce any warnings.
+        allWarningsAsErrors = true
+
+        freeCompilerArgs += [
+          // Ensure our codegen can handle the latest language semantics.
+          // https://kotlinlang.org/docs/compiler-reference.html#progressive
+          '-progressive'
+        ]
+      }
+    }
+  }
+}
+
+redwoodSchema {
+  source = projects.testSchema
+  type = 'example.redwood.ExampleSchema'
+}