This is an automated email from the ASF dual-hosted git repository.

He-Pin pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/pekko.git


The following commit(s) were added to refs/heads/main by this push:
     new 5379ab36b1 Add DurableStateBehaviorTestKit (#3360)
5379ab36b1 is described below

commit 5379ab36b18b96940c7faef0778ad39f33429e54
Author: He-Pin(kerr) <[email protected]>
AuthorDate: Sun Jul 19 04:20:31 2026 +0800

    Add DurableStateBehaviorTestKit (#3360)
    
    Motivation:
    DurableStateBehavior lacks the high-level actor-based test API available 
for EventSourcedBehavior.
    
    Modification:
    Add matching Scala and Java DurableStateBehaviorTestKit APIs for commands, 
replies, state inspection, serialization checks, restart, and clear. Isolate 
the in-memory store between test kits, make persistence-id discovery work with 
catch-all signal handlers, use generated Apache headers for new files, and add 
documentation and directional tests.
    
    Result:
    Durable state behaviors can be exercised through a synchronous high-level 
test API with isolated state and aligned Scala and Java DSLs.
    
    Tests:
    - Scala 2.13 and 3.3.8 focused DurableStateBehaviorTestKit suites (17 
passed on each)
    - sbt +mimaReportBinaryIssues
    - sbt docs/paradox (passed with existing duplicate-anchor warnings)
    - sbt checkCodeStyle
    - sbt headerCreateAll +headerCheckAll
    - JDK 17: sbt javafmtAll javafmtCheckAll
    - scalafmt --list --mode diff-ref=origin/main
    - git diff --check
    - Qoder stdout review: No must-fix findings
    
    References:
    Fixes #3236
---
 docs/src/main/paradox/typed/persistence-testing.md |  26 +++
 .../internal/DurableStateBehaviorTestKitImpl.scala | 204 +++++++++++++++++
 .../javadsl/DurableStateBehaviorTestKit.scala      | 232 +++++++++++++++++++
 .../scaladsl/DurableStateBehaviorTestKit.scala     | 209 +++++++++++++++++
 .../PersistenceTestKitDurableStateStore.scala      |   8 +
 .../DurableStateBehaviorTestKitApiTest.java        | 139 ++++++++++++
 .../DurableStateBehaviorTestKitJavaDslSpec.scala   |  39 ++++
 .../scaladsl/DurableStateBehaviorTestKitSpec.scala | 250 +++++++++++++++++++++
 .../state/internal/DurableStateBehaviorImpl.scala  |  19 +-
 .../typed/state/internal/Recovering.scala          |  20 +-
 .../persistence/typed/state/internal/Running.scala |  48 ++--
 11 files changed, 1170 insertions(+), 24 deletions(-)

diff --git a/docs/src/main/paradox/typed/persistence-testing.md 
b/docs/src/main/paradox/typed/persistence-testing.md
index 17fe88475f..d1773fe11d 100644
--- a/docs/src/main/paradox/typed/persistence-testing.md
+++ b/docs/src/main/paradox/typed/persistence-testing.md
@@ -95,6 +95,32 @@ To test recovery the `restart` method of the 
`EventSourcedBehaviorTestKit` can b
 behavior, which will then recover from stored snapshot and events from 
previous commands. It's also possible
 to populate the storage with events or simulate failures by using the 
underlying @apidoc[PersistenceTestKit].
 
+## Unit testing with the ActorTestKit and DurableStateBehaviorTestKit
+
+**Note!** The `DurableStateBehaviorTestKit` is a new feature: the API may have 
changes breaking source compatibility in future versions.
+
+Unit testing of `DurableStateBehavior` can be done with the 
@apidoc[DurableStateBehaviorTestKit]. It supports
+running one command at a time and asserting that the synchronously returned 
state is as expected. It also has
+support for verifying the reply to a command.
+
+You need to configure the `ActorSystem` with the 
`DurableStateBehaviorTestKit.config`. The configuration enables
+the in-memory durable state store.
+
+Scala
+:  @@snip 
[DurableStateBehaviorTestKitSpec.scala](/persistence-testkit/src/test/scala/org/apache/pekko/persistence/testkit/scaladsl/DurableStateBehaviorTestKitSpec.scala)
 { #basic-test }
+
+Java
+:  @@snip 
[DurableStateBehaviorTestKitApiTest.java](/persistence-testkit/src/test/java/org/apache/pekko/persistence/testkit/javadsl/DurableStateBehaviorTestKitApiTest.java)
 { #basic-test }
+
+Serialization of commands and state is verified automatically. The 
serialization checks can be customized with
+the `SerializationSettings` when creating the `DurableStateBehaviorTestKit`. 
By default, the serialization
+roundtrip is checked but the equality of the result of the serialization is 
not checked. `equals` must be
+implemented @scala[(or using `case class`)] in the commands and state if 
`verifyEquality` is enabled.
+
+To test recovery, use the `restart` method. It restarts the behavior, which 
then recovers the state stored by
+previous commands. The `clear` method removes the state for the behavior's 
persistence ID and restarts it
+with its empty state.
+
 ## Persistence TestKit
 
 **Note!** The `PersistenceTestKit` is a new feature, api may have changes 
breaking source compatibility in future versions.
diff --git 
a/persistence-testkit/src/main/scala/org/apache/pekko/persistence/testkit/internal/DurableStateBehaviorTestKitImpl.scala
 
b/persistence-testkit/src/main/scala/org/apache/pekko/persistence/testkit/internal/DurableStateBehaviorTestKitImpl.scala
new file mode 100644
index 0000000000..726027db65
--- /dev/null
+++ 
b/persistence-testkit/src/main/scala/org/apache/pekko/persistence/testkit/internal/DurableStateBehaviorTestKitImpl.scala
@@ -0,0 +1,204 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.pekko.persistence.testkit.internal
+
+import scala.reflect.ClassTag
+import scala.util.control.NonFatal
+
+import org.apache.pekko
+import pekko.actor.testkit.typed.scaladsl.ActorTestKit
+import pekko.actor.testkit.typed.scaladsl.SerializationTestKit
+import pekko.actor.typed.ActorRef
+import pekko.actor.typed.ActorSystem
+import pekko.actor.typed.Behavior
+import pekko.actor.typed.scaladsl.adapter._
+import pekko.annotation.InternalApi
+import pekko.persistence.state.DurableStateStoreRegistry
+import pekko.persistence.testkit.scaladsl.DurableStateBehaviorTestKit
+import 
pekko.persistence.testkit.scaladsl.DurableStateBehaviorTestKit.CommandResult
+import 
pekko.persistence.testkit.scaladsl.DurableStateBehaviorTestKit.CommandResultWithReply
+import 
pekko.persistence.testkit.scaladsl.DurableStateBehaviorTestKit.RestartResult
+import 
pekko.persistence.testkit.scaladsl.DurableStateBehaviorTestKit.SerializationSettings
+import 
pekko.persistence.testkit.state.scaladsl.PersistenceTestKitDurableStateStore
+import pekko.persistence.typed.PersistenceId
+import pekko.persistence.typed.state.internal.DurableStateBehaviorImpl
+import 
pekko.persistence.typed.state.internal.DurableStateBehaviorImpl.GetStateReply
+
+/**
+ * INTERNAL API
+ */
+@InternalApi private[pekko] object DurableStateBehaviorTestKitImpl {
+
+  final case class CommandResultImpl[Command, State, Reply](
+      command: Command,
+      state: State,
+      replyOption: Option[Reply])
+      extends CommandResultWithReply[Command, State, Reply] {
+
+    override def stateOfType[S <: State: ClassTag]: S =
+      ofType(state, "state")
+
+    override def reply: Reply =
+      replyOption.getOrElse(throw new AssertionError("No reply"))
+
+    override def replyOfType[R <: Reply: ClassTag]: R =
+      ofType(reply, "reply")
+
+    override def hasNoReply: Boolean =
+      replyOption.isEmpty
+
+    private def ofType[A: ClassTag](obj: Any, errorParam: String): A =
+      obj match {
+        case a: A  => a
+        case other =>
+          val expectedClass = implicitly[ClassTag[A]].runtimeClass
+          val actualClass = if (other == null) "null" else 
other.getClass.getName
+          throw new AssertionError(
+            s"Expected $errorParam class [${expectedClass.getName}], " +
+            s"but was [$actualClass]")
+      }
+  }
+
+  final case class RestartResultImpl[State](state: State) extends 
RestartResult[State]
+}
+
+/**
+ * INTERNAL API
+ */
+@InternalApi private[pekko] class DurableStateBehaviorTestKitImpl[Command, 
State](
+    actorTestKit: ActorTestKit,
+    behavior: Behavior[Command],
+    serializationSettings: SerializationSettings)
+    extends DurableStateBehaviorTestKit[Command, State] {
+
+  import DurableStateBehaviorTestKitImpl._
+
+  private def system: ActorSystem[?] = actorTestKit.system
+
+  private val durableStateStore =
+    DurableStateStoreRegistry(system.toClassic)
+      .durableStateStoreFor[PersistenceTestKitDurableStateStore[Any]](
+        PersistenceTestKitDurableStateStore.Identifier)
+  durableStateStore.clearAll()
+
+  private val probe = actorTestKit.createTestProbe[Any]()
+  private val stateProbe = actorTestKit.createTestProbe[GetStateReply[State]]()
+  private var actor: ActorRef[Command] = actorTestKit.spawn(behavior)
+  private def internalActor: ActorRef[Any] = actor.unsafeUpcast[Any]
+  private val persistenceId = verifyDurableStateBehavior()
+
+  private val serializationTestKit = new SerializationTestKit(system)
+  private var emptyStateVerified = false
+
+  override def runCommand(command: Command): CommandResult[Command, State] = {
+    preCommandCheck(command)
+
+    actor ! command
+
+    val newState = getState()
+    postCommandCheck(newState, reply = None)
+
+    CommandResultImpl[Command, State, Nothing](command, newState, None)
+  }
+
+  override def runCommand[R](creator: ActorRef[R] => Command): 
CommandResultWithReply[Command, State, R] = {
+    val replyProbe = actorTestKit.createTestProbe[R]()
+    val command = creator(replyProbe.ref)
+    preCommandCheck(command)
+
+    actor ! command
+
+    val reply =
+      try {
+        replyProbe.receiveMessage()
+      } catch {
+        case NonFatal(_) =>
+          throw new AssertionError(s"Missing expected reply for command 
[$command].")
+      } finally {
+        replyProbe.stop()
+      }
+
+    val newState = getState()
+    postCommandCheck(newState, Some(reply))
+
+    CommandResultImpl(command, newState, Some(reply))
+  }
+
+  override def getState(): State = {
+    internalActor ! DurableStateBehaviorImpl.GetStateWithReply(stateProbe.ref)
+    stateProbe.receiveMessage().currentState
+  }
+
+  override def restart(): RestartResult[State] = {
+    actorTestKit.stop(actor)
+    actor = actorTestKit.spawn(behavior)
+    try {
+      RestartResultImpl(getState())
+    } catch {
+      case NonFatal(_) =>
+        throw new IllegalStateException("Could not restart 
DurableStateBehavior. See logs.")
+    }
+  }
+
+  override def clear(): Unit = {
+    durableStateStore.clearByPersistenceId(persistenceId.id)
+    restart()
+  }
+
+  private def verifyDurableStateBehavior(): PersistenceId = {
+    internalActor ! 
DurableStateBehaviorImpl.GetPersistenceIdForTestKit(probe.ref)
+    try {
+      probe.expectMessageType[PersistenceId]
+    } catch {
+      case NonFatal(_) =>
+        throw new IllegalArgumentException("Only DurableStateBehavior, or 
nested DurableStateBehavior allowed.")
+    }
+  }
+
+  private def preCommandCheck(command: Command): Unit = {
+    if (serializationSettings.enabled) {
+      if (serializationSettings.verifyCommands)
+        verifySerializationAndThrow(command, "Command")
+
+      if (serializationSettings.verifyState && !emptyStateVerified) {
+        val emptyState = getState()
+        verifySerializationAndThrow(emptyState, "Empty State")
+        emptyStateVerified = true
+      }
+    }
+  }
+
+  private def postCommandCheck(newState: State, reply: Option[Any]): Unit = {
+    if (serializationSettings.enabled) {
+      if (serializationSettings.verifyState)
+        verifySerializationAndThrow(newState, "State")
+
+      if (serializationSettings.verifyCommands)
+        reply.foreach(verifySerializationAndThrow(_, "Reply"))
+    }
+  }
+
+  private def verifySerializationAndThrow(obj: Any, errorMessagePrefix: 
String): Unit = {
+    try {
+      serializationTestKit.verifySerialization(obj, 
serializationSettings.verifyEquality)
+    } catch {
+      case NonFatal(exc) =>
+        throw new IllegalArgumentException(s"$errorMessagePrefix [$obj] isn't 
serializable.", exc)
+    }
+  }
+}
diff --git 
a/persistence-testkit/src/main/scala/org/apache/pekko/persistence/testkit/javadsl/DurableStateBehaviorTestKit.scala
 
b/persistence-testkit/src/main/scala/org/apache/pekko/persistence/testkit/javadsl/DurableStateBehaviorTestKit.scala
new file mode 100644
index 0000000000..b0c5d59ba7
--- /dev/null
+++ 
b/persistence-testkit/src/main/scala/org/apache/pekko/persistence/testkit/javadsl/DurableStateBehaviorTestKit.scala
@@ -0,0 +1,232 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.pekko.persistence.testkit.javadsl
+
+import java.util.function.{ Function => JFunction }
+
+import scala.reflect.ClassTag
+
+import org.apache.pekko
+import pekko.actor.typed.ActorRef
+import pekko.actor.typed.ActorSystem
+import pekko.actor.typed.Behavior
+import pekko.annotation.ApiMayChange
+import pekko.annotation.DoNotInherit
+import pekko.persistence.testkit.scaladsl
+
+import com.typesafe.config.Config
+
+/**
+ * Testing of [[pekko.persistence.typed.state.javadsl.DurableStateBehavior]] 
implementations.
+ * It supports running one command at a time and asserting that the 
synchronously returned result is as expected.
+ * The result contains the new state after the command has been processed.
+ * It also has support for verifying the reply to a command.
+ *
+ * Serialization of commands and state is verified automatically.
+ *
+ * @since 2.0.0
+ */
+@ApiMayChange
+object DurableStateBehaviorTestKit {
+
+  /**
+   * The configuration to be included in the configuration of the 
`ActorSystem`. Typically used as
+   * constructor parameter to `TestKitJunitResource`. The configuration 
enables the in-memory
+   * durable state store.
+   */
+  val config: Config = scaladsl.DurableStateBehaviorTestKit.config
+
+  val enabledSerializationSettings: SerializationSettings = new 
SerializationSettings(
+    enabled = true,
+    verifyEquality = false,
+    verifyCommands = true,
+    verifyState = true)
+
+  val disabledSerializationSettings: SerializationSettings = new 
SerializationSettings(
+    enabled = false,
+    verifyEquality = false,
+    verifyCommands = false,
+    verifyState = false)
+
+  /**
+   * Customization of which serialization checks that are performed.
+   * `equals` must be implemented when `verifyEquality` is enabled.
+   */
+  final class SerializationSettings(
+      val enabled: Boolean,
+      val verifyEquality: Boolean,
+      val verifyCommands: Boolean,
+      val verifyState: Boolean) {
+
+    def withEnabled(value: Boolean): SerializationSettings =
+      copy(enabled = value)
+
+    def withVerifyEquality(value: Boolean): SerializationSettings =
+      copy(verifyEquality = value)
+
+    def withVerifyCommands(value: Boolean): SerializationSettings =
+      copy(verifyCommands = value)
+
+    def withVerifyState(value: Boolean): SerializationSettings =
+      copy(verifyState = value)
+
+    private def copy(
+        enabled: Boolean = this.enabled,
+        verifyEquality: Boolean = this.verifyEquality,
+        verifyCommands: Boolean = this.verifyCommands,
+        verifyState: Boolean = this.verifyState): SerializationSettings =
+      new SerializationSettings(enabled, verifyEquality, verifyCommands, 
verifyState)
+  }
+
+  /**
+   * Factory method to create a new DurableStateBehaviorTestKit.
+   */
+  def create[Command, State](
+      system: ActorSystem[?],
+      behavior: Behavior[Command]): DurableStateBehaviorTestKit[Command, 
State] =
+    create(system, behavior, enabledSerializationSettings)
+
+  /**
+   * Factory method to create a new DurableStateBehaviorTestKit with custom 
[[SerializationSettings]].
+   *
+   * Note that `equals` must be implemented in the commands and state if 
`verifyEquality` is enabled.
+   */
+  def create[Command, State](
+      system: ActorSystem[?],
+      behavior: Behavior[Command],
+      serializationSettings: SerializationSettings): 
DurableStateBehaviorTestKit[Command, State] = {
+    val scaladslSettings = new 
scaladsl.DurableStateBehaviorTestKit.SerializationSettings(
+      enabled = serializationSettings.enabled,
+      verifyEquality = serializationSettings.verifyEquality,
+      verifyCommands = serializationSettings.verifyCommands,
+      verifyState = serializationSettings.verifyState)
+    new 
DurableStateBehaviorTestKit(scaladsl.DurableStateBehaviorTestKit(system, 
behavior, scaladslSettings))
+  }
+
+  /**
+   * The result of running a command.
+   */
+  @DoNotInherit class CommandResult[Command, State](
+      delegate: scaladsl.DurableStateBehaviorTestKit.CommandResult[Command, 
State]) {
+
+    /**
+     * The command that was run.
+     */
+    def command: Command =
+      delegate.command
+
+    /**
+     * The state after the command has been processed.
+     */
+    def state: State =
+      delegate.state
+
+    /**
+     * The state as a given expected type. It will throw `AssertionError` if 
the state is of a different type.
+     */
+    def stateOfType[S <: State](stateClass: Class[S]): S = {
+      implicit val ct: ClassTag[S] = ClassTag(stateClass)
+      delegate.stateOfType[S]
+    }
+  }
+
+  /**
+   * The result of running a command with an `ActorRef<R> replyTo`, i.e. the 
`runCommand` with a
+   * `Function<ActorRef<R>, Command>` parameter.
+   */
+  final class CommandResultWithReply[Command, State, Reply](
+      delegate: 
scaladsl.DurableStateBehaviorTestKit.CommandResultWithReply[Command, State, 
Reply])
+      extends CommandResult[Command, State](delegate) {
+
+    /**
+     * The reply. It will throw `AssertionError` if there was no reply.
+     */
+    def reply: Reply =
+      delegate.reply
+
+    /**
+     * The reply as a given expected type. It will throw `AssertionError` if 
there is no reply or
+     * if the reply is of a different type.
+     */
+    def replyOfType[R <: Reply](replyClass: Class[R]): R = {
+      implicit val ct: ClassTag[R] = ClassTag(replyClass)
+      delegate.replyOfType[R]
+    }
+
+    /**
+     * `true` if there is no reply.
+     */
+    def hasNoReply: Boolean =
+      delegate.hasNoReply
+  }
+
+  /**
+   * The result of restarting the behavior.
+   */
+  final class RestartResult[State](delegate: 
scaladsl.DurableStateBehaviorTestKit.RestartResult[State]) {
+
+    /**
+     * The state after recovery.
+     */
+    def state: State =
+      delegate.state
+  }
+}
+
+/**
+ * @since 2.0.0
+ */
+@ApiMayChange
+final class DurableStateBehaviorTestKit[Command, State](
+    delegate: scaladsl.DurableStateBehaviorTestKit[Command, State]) {
+
+  import DurableStateBehaviorTestKit._
+
+  /**
+   * Run one command through the behavior. The returned result contains the 
state after the command
+   * has been processed.
+   */
+  def runCommand(command: Command): CommandResult[Command, State] =
+    new CommandResult(delegate.runCommand(command))
+
+  /**
+   * Run one command with a `replyTo: ActorRef` through the behavior. The 
returned result contains
+   * the state after the command has been processed, and the reply.
+   */
+  def runCommand[R](creator: JFunction[ActorRef[R], Command]): 
CommandResultWithReply[Command, State, R] =
+    new CommandResultWithReply(delegate.runCommand(replyTo => 
creator.apply(replyTo)))
+
+  /**
+   * Retrieve the current state of the behavior.
+   */
+  def getState(): State =
+    delegate.getState()
+
+  /**
+   * Restart the behavior, which will then recover from the durable state 
store. Can be used for testing
+   * that the recovery is correct.
+   */
+  def restart(): RestartResult[State] =
+    new RestartResult(delegate.restart())
+
+  /**
+   * Clear the state for this behavior from the in-memory durable state store 
and restart the behavior.
+   */
+  def clear(): Unit =
+    delegate.clear()
+}
diff --git 
a/persistence-testkit/src/main/scala/org/apache/pekko/persistence/testkit/scaladsl/DurableStateBehaviorTestKit.scala
 
b/persistence-testkit/src/main/scala/org/apache/pekko/persistence/testkit/scaladsl/DurableStateBehaviorTestKit.scala
new file mode 100644
index 0000000000..9ebd9ddad3
--- /dev/null
+++ 
b/persistence-testkit/src/main/scala/org/apache/pekko/persistence/testkit/scaladsl/DurableStateBehaviorTestKit.scala
@@ -0,0 +1,209 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.pekko.persistence.testkit.scaladsl
+
+import scala.reflect.ClassTag
+
+import org.apache.pekko
+import pekko.actor.testkit.typed.scaladsl.ActorTestKit
+import pekko.actor.typed.ActorRef
+import pekko.actor.typed.ActorSystem
+import pekko.actor.typed.Behavior
+import pekko.annotation.ApiMayChange
+import pekko.annotation.DoNotInherit
+import pekko.persistence.testkit.PersistenceTestKitDurableStateStorePlugin
+import pekko.persistence.testkit.internal.DurableStateBehaviorTestKitImpl
+
+import com.typesafe.config.Config
+
+/**
+ * Testing of [[pekko.persistence.typed.state.scaladsl.DurableStateBehavior]] 
implementations.
+ * It supports running one command at a time and asserting that the 
synchronously returned result is as expected.
+ * The result contains the new state after the command has been processed.
+ * It also has support for verifying the reply to a command.
+ *
+ * Serialization of commands and state is verified automatically.
+ *
+ * @since 2.0.0
+ */
+@ApiMayChange
+object DurableStateBehaviorTestKit {
+
+  /**
+   * The configuration to be included in the configuration of the 
`ActorSystem`. Typically used as
+   * constructor parameter to `ScalaTestWithActorTestKit`. The configuration 
enables the in-memory
+   * durable state store.
+   */
+  val config: Config = PersistenceTestKitDurableStateStorePlugin.config
+
+  object SerializationSettings {
+    val enabled: SerializationSettings = new SerializationSettings(
+      enabled = true,
+      verifyEquality = false,
+      verifyCommands = true,
+      verifyState = true)
+
+    val disabled: SerializationSettings = new SerializationSettings(
+      enabled = false,
+      verifyEquality = false,
+      verifyCommands = false,
+      verifyState = false)
+  }
+
+  /**
+   * Customization of which serialization checks that are performed.
+   * `equals` must be implemented (or using `case class`) when 
`verifyEquality` is enabled.
+   */
+  final class SerializationSettings private[pekko] (
+      val enabled: Boolean,
+      val verifyEquality: Boolean,
+      val verifyCommands: Boolean,
+      val verifyState: Boolean) {
+
+    def withEnabled(value: Boolean): SerializationSettings =
+      copy(enabled = value)
+
+    def withVerifyEquality(value: Boolean): SerializationSettings =
+      copy(verifyEquality = value)
+
+    def withVerifyCommands(value: Boolean): SerializationSettings =
+      copy(verifyCommands = value)
+
+    def withVerifyState(value: Boolean): SerializationSettings =
+      copy(verifyState = value)
+
+    private def copy(
+        enabled: Boolean = this.enabled,
+        verifyEquality: Boolean = this.verifyEquality,
+        verifyCommands: Boolean = this.verifyCommands,
+        verifyState: Boolean = this.verifyState): SerializationSettings =
+      new SerializationSettings(enabled, verifyEquality, verifyCommands, 
verifyState)
+  }
+
+  /**
+   * Factory method to create a new DurableStateBehaviorTestKit.
+   */
+  def apply[Command, State](
+      system: ActorSystem[?],
+      behavior: Behavior[Command]): DurableStateBehaviorTestKit[Command, 
State] =
+    apply(system, behavior, SerializationSettings.enabled)
+
+  /**
+   * Factory method to create a new DurableStateBehaviorTestKit with custom 
[[SerializationSettings]].
+   *
+   * Note that `equals` must be implemented (or using `case class`) in the 
commands and state if
+   * `verifyEquality` is enabled.
+   */
+  def apply[Command, State](
+      system: ActorSystem[?],
+      behavior: Behavior[Command],
+      serializationSettings: SerializationSettings): 
DurableStateBehaviorTestKit[Command, State] =
+    new DurableStateBehaviorTestKitImpl(ActorTestKit(system), behavior, 
serializationSettings)
+
+  /**
+   * The result of running a command.
+   */
+  @DoNotInherit trait CommandResult[Command, State] {
+
+    /**
+     * The command that was run.
+     */
+    def command: Command
+
+    /**
+     * The state after the command has been processed.
+     */
+    def state: State
+
+    /**
+     * The state as a given expected type. It will throw `AssertionError` if 
the state is of a different type.
+     */
+    def stateOfType[S <: State: ClassTag]: S
+  }
+
+  /**
+   * The result of running a command with a `replyTo: ActorRef[R]`, i.e. the 
`runCommand` with an
+   * `ActorRef[R] => Command` parameter.
+   */
+  @DoNotInherit trait CommandResultWithReply[Command, State, Reply] extends 
CommandResult[Command, State] {
+
+    /**
+     * The reply. It will throw `AssertionError` if there was no reply.
+     */
+    def reply: Reply
+
+    /**
+     * The reply as a given expected type. It will throw `AssertionError` if 
there is no reply or
+     * if the reply is of a different type.
+     */
+    def replyOfType[R <: Reply: ClassTag]: R
+
+    /**
+     * `true` if there is no reply.
+     */
+    def hasNoReply: Boolean
+  }
+
+  /**
+   * The result of restarting the behavior.
+   */
+  @DoNotInherit trait RestartResult[State] {
+
+    /**
+     * The state after recovery.
+     */
+    def state: State
+  }
+}
+
+/**
+ * @since 2.0.0
+ */
+@ApiMayChange
+@DoNotInherit trait DurableStateBehaviorTestKit[Command, State] {
+
+  import DurableStateBehaviorTestKit._
+
+  /**
+   * Run one command through the behavior. The returned result contains the 
state after the command
+   * has been processed.
+   */
+  def runCommand(command: Command): CommandResult[Command, State]
+
+  /**
+   * Run one command with a `replyTo: ActorRef[R]` through the behavior. The 
returned result contains
+   * the state after the command has been processed, and the reply.
+   */
+  def runCommand[R](creator: ActorRef[R] => Command): 
CommandResultWithReply[Command, State, R]
+
+  /**
+   * Retrieve the current state of the behavior.
+   */
+  def getState(): State
+
+  /**
+   * Restart the behavior, which will then recover from the durable state 
store. Can be used for testing
+   * that the recovery is correct.
+   */
+  def restart(): RestartResult[State]
+
+  /**
+   * Clear the state for this behavior from the in-memory durable state store 
and restart the behavior.
+   */
+  def clear(): Unit
+}
diff --git 
a/persistence-testkit/src/main/scala/org/apache/pekko/persistence/testkit/state/scaladsl/PersistenceTestKitDurableStateStore.scala
 
b/persistence-testkit/src/main/scala/org/apache/pekko/persistence/testkit/state/scaladsl/PersistenceTestKitDurableStateStore.scala
index 414a9f1962..f56b658e99 100644
--- 
a/persistence-testkit/src/main/scala/org/apache/pekko/persistence/testkit/state/scaladsl/PersistenceTestKitDurableStateStore.scala
+++ 
b/persistence-testkit/src/main/scala/org/apache/pekko/persistence/testkit/state/scaladsl/PersistenceTestKitDurableStateStore.scala
@@ -54,6 +54,14 @@ class PersistenceTestKitDurableStateStore[A](val system: 
ExtendedActorSystem)
   private val persistence = Persistence(system)
   private var store = Map.empty[String, Record[A]]
 
+  private[pekko] def clearAll(): Unit = this.synchronized {
+    store = Map.empty
+  }
+
+  private[pekko] def clearByPersistenceId(persistenceId: String): Unit = 
this.synchronized {
+    store = store - persistenceId
+  }
+
   private val (publisher, changesSource) =
     ActorSource
       .actorRef[Record[A]](PartialFunction.empty, PartialFunction.empty, 256, 
OverflowStrategy.dropHead)
diff --git 
a/persistence-testkit/src/test/java/org/apache/pekko/persistence/testkit/javadsl/DurableStateBehaviorTestKitApiTest.java
 
b/persistence-testkit/src/test/java/org/apache/pekko/persistence/testkit/javadsl/DurableStateBehaviorTestKitApiTest.java
new file mode 100644
index 0000000000..e2733b20b7
--- /dev/null
+++ 
b/persistence-testkit/src/test/java/org/apache/pekko/persistence/testkit/javadsl/DurableStateBehaviorTestKitApiTest.java
@@ -0,0 +1,139 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.pekko.persistence.testkit.javadsl;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import org.apache.pekko.Done;
+import org.apache.pekko.actor.typed.ActorRef;
+import org.apache.pekko.actor.typed.ActorSystem;
+import org.apache.pekko.actor.typed.Behavior;
+import org.apache.pekko.persistence.typed.PersistenceId;
+import org.apache.pekko.persistence.typed.state.javadsl.CommandHandler;
+import org.apache.pekko.persistence.typed.state.javadsl.DurableStateBehavior;
+import org.apache.pekko.serialization.jackson.CborSerializable;
+
+public final class DurableStateBehaviorTestKitApiTest {
+
+  interface Command extends CborSerializable {}
+
+  enum Increment implements Command {
+    INSTANCE
+  }
+
+  static final class IncrementWithReply implements Command {
+    final ActorRef<Done> replyTo;
+
+    @JsonCreator
+    IncrementWithReply(@JsonProperty("replyTo") ActorRef<Done> replyTo) {
+      this.replyTo = replyTo;
+    }
+  }
+
+  static final class State implements CborSerializable {
+    final int value;
+
+    @JsonCreator
+    State(@JsonProperty("value") int value) {
+      this.value = value;
+    }
+  }
+
+  static final class Counter extends DurableStateBehavior<Command, State> {
+
+    static Behavior<Command> create(PersistenceId persistenceId) {
+      return new Counter(persistenceId);
+    }
+
+    private Counter(PersistenceId persistenceId) {
+      super(persistenceId);
+    }
+
+    @Override
+    public State emptyState() {
+      return new State(0);
+    }
+
+    @Override
+    public CommandHandler<Command, State> commandHandler() {
+      return newCommandHandlerBuilder()
+          .forAnyState()
+          .onCommand(
+              Increment.class, (state, command) -> Effect().persist(new 
State(state.value + 1)))
+          .onCommand(
+              IncrementWithReply.class,
+              (state, command) ->
+                  Effect()
+                      .persist(new State(state.value + 1))
+                      .thenReply(command.replyTo, ignored -> 
Done.getInstance()))
+          .build();
+    }
+  }
+
+  private static final AtomicInteger idCounter = new AtomicInteger();
+
+  // #basic-test
+  public static void run(ActorSystem<?> system) {
+    PersistenceId persistenceId =
+        PersistenceId.ofUniqueId("durable-state-java-test-" + 
idCounter.incrementAndGet());
+    DurableStateBehaviorTestKit<Command, State> testKit =
+        DurableStateBehaviorTestKit.create(system, 
Counter.create(persistenceId));
+
+    DurableStateBehaviorTestKit.CommandResult<Command, State> commandResult =
+        testKit.runCommand(Increment.INSTANCE);
+    check(commandResult.command() == Increment.INSTANCE, "command");
+    check(commandResult.state().value == 1, "first command state");
+    check(commandResult.stateOfType(State.class).value == 1, "typed state");
+
+    DurableStateBehaviorTestKit.CommandResultWithReply<Command, State, Done> 
replyResult =
+        testKit.runCommand(replyTo -> new IncrementWithReply(replyTo));
+    check(replyResult.state().value == 2, "reply command state");
+    check(replyResult.reply() == Done.getInstance(), "reply");
+    check(replyResult.replyOfType(Done.class) == Done.getInstance(), "typed 
reply");
+    check(!replyResult.hasNoReply(), "has reply");
+
+    check(testKit.getState().value == 2, "current state");
+    check(testKit.restart().state().value == 2, "recovered state");
+  }
+
+  // #basic-test
+
+  public static void clear(ActorSystem<?> system) {
+    PersistenceId persistenceId =
+        PersistenceId.ofUniqueId("durable-state-java-clear-" + 
idCounter.incrementAndGet());
+    DurableStateBehaviorTestKit<Command, State> testKit =
+        DurableStateBehaviorTestKit.create(
+            system,
+            Counter.create(persistenceId),
+            DurableStateBehaviorTestKit.enabledSerializationSettings());
+
+    testKit.runCommand(Increment.INSTANCE);
+    testKit.clear();
+    check(testKit.getState().value == 0, "cleared state");
+  }
+
+  private static void check(boolean condition, String clue) {
+    if (!condition) {
+      throw new AssertionError(clue);
+    }
+  }
+
+  private DurableStateBehaviorTestKitApiTest() {}
+}
diff --git 
a/persistence-testkit/src/test/scala/org/apache/pekko/persistence/testkit/javadsl/DurableStateBehaviorTestKitJavaDslSpec.scala
 
b/persistence-testkit/src/test/scala/org/apache/pekko/persistence/testkit/javadsl/DurableStateBehaviorTestKitJavaDslSpec.scala
new file mode 100644
index 0000000000..9d0fc4003c
--- /dev/null
+++ 
b/persistence-testkit/src/test/scala/org/apache/pekko/persistence/testkit/javadsl/DurableStateBehaviorTestKitJavaDslSpec.scala
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.pekko.persistence.testkit.javadsl
+
+import org.apache.pekko.actor.testkit.typed.scaladsl.LogCapturing
+import org.apache.pekko.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit
+
+import org.scalatest.wordspec.AnyWordSpecLike
+
+class DurableStateBehaviorTestKitJavaDslSpec
+    extends ScalaTestWithActorTestKit(DurableStateBehaviorTestKit.config)
+    with AnyWordSpecLike
+    with LogCapturing {
+
+  "The Java DurableStateBehaviorTestKit API" must {
+    "run commands, return replies, expose state, and restart" in {
+      DurableStateBehaviorTestKitApiTest.run(system)
+    }
+
+    "clear the state" in {
+      DurableStateBehaviorTestKitApiTest.clear(system)
+    }
+  }
+}
diff --git 
a/persistence-testkit/src/test/scala/org/apache/pekko/persistence/testkit/scaladsl/DurableStateBehaviorTestKitSpec.scala
 
b/persistence-testkit/src/test/scala/org/apache/pekko/persistence/testkit/scaladsl/DurableStateBehaviorTestKitSpec.scala
new file mode 100644
index 0000000000..4897e381fe
--- /dev/null
+++ 
b/persistence-testkit/src/test/scala/org/apache/pekko/persistence/testkit/scaladsl/DurableStateBehaviorTestKitSpec.scala
@@ -0,0 +1,250 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.pekko.persistence.testkit.scaladsl
+
+import java.io.NotSerializableException
+
+import org.apache.pekko
+import pekko.Done
+import pekko.actor.testkit.typed.scaladsl.LogCapturing
+import pekko.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit
+import pekko.actor.typed.ActorRef
+import pekko.actor.typed.scaladsl.Behaviors
+import 
pekko.persistence.testkit.scaladsl.DurableStateBehaviorTestKitSpec.TestCounter
+import pekko.persistence.typed.PersistenceId
+import pekko.persistence.typed.state.scaladsl.DurableStateBehavior
+import pekko.persistence.typed.state.scaladsl.Effect
+import pekko.serialization.DisabledJavaSerializer
+import pekko.serialization.jackson.CborSerializable
+
+import org.scalatest.wordspec.AnyWordSpecLike
+
+object DurableStateBehaviorTestKitSpec {
+
+  object TestCounter {
+    sealed trait Command
+    case object Increment extends Command with CborSerializable
+    final case class IncrementWithConfirmation(replyTo: ActorRef[Done]) 
extends Command with CborSerializable
+    final case class GetValue(replyTo: ActorRef[State]) extends Command with 
CborSerializable
+    case object PersistNotSerializableState extends Command with 
CborSerializable
+    case object NotSerializableCommand extends Command
+    final case class ReplyNotSerializable(replyTo: 
ActorRef[NotSerializableReply])
+        extends Command
+        with CborSerializable
+
+    sealed trait State
+    final case class RealState(value: Int) extends State with CborSerializable
+    final case class NotSerializableState(value: Int) extends State
+    final class NotSerializableReply
+
+    def apply(persistenceId: PersistenceId, emptyState: State = RealState(0)): 
DurableStateBehavior[Command, State] =
+      DurableStateBehavior[Command, State](
+        persistenceId,
+        emptyState,
+        commandHandler = {
+          case (RealState(value), Increment) =>
+            Effect.persist(RealState(value + 1))
+          case (null, Increment) =>
+            Effect.persist(RealState(1))
+          case (RealState(value), IncrementWithConfirmation(replyTo)) =>
+            Effect.persist(RealState(value + 1)).thenReply(replyTo)(_ => Done)
+          case (state, GetValue(replyTo)) =>
+            Effect.none[TestCounter.State].thenRun(_ => replyTo ! state)
+          case (RealState(value), PersistNotSerializableState) =>
+            Effect.persist(NotSerializableState(value + 1))
+          case (_, NotSerializableCommand) =>
+            Effect.none
+          case (_, ReplyNotSerializable(replyTo)) =>
+            Effect.none[TestCounter.State].thenRun(_ => replyTo ! new 
NotSerializableReply)
+          case (state, command) =>
+            throw new IllegalArgumentException(s"Unexpected state [$state] and 
command [$command]")
+        })
+  }
+}
+
+class DurableStateBehaviorTestKitSpec
+    extends ScalaTestWithActorTestKit(DurableStateBehaviorTestKit.config)
+    with AnyWordSpecLike
+    with LogCapturing {
+
+  private val persistenceId = PersistenceId.ofUniqueId("durable-state-test")
+
+  private def createTestKit(
+      emptyState: TestCounter.State = TestCounter.RealState(0),
+      settings: DurableStateBehaviorTestKit.SerializationSettings =
+        DurableStateBehaviorTestKit.SerializationSettings.enabled) =
+    DurableStateBehaviorTestKit[TestCounter.Command, TestCounter.State](
+      system,
+      TestCounter(persistenceId, emptyState),
+      settings)
+
+  "DurableStateBehaviorTestKit" must {
+
+    "run commands and expose the resulting state" in {
+      // #basic-test
+      val testKit =
+        DurableStateBehaviorTestKit[TestCounter.Command, TestCounter.State](
+          system,
+          TestCounter(PersistenceId.ofUniqueId("durable-state-test")))
+
+      val firstResult = testKit.runCommand(TestCounter.Increment)
+      firstResult.command shouldBe TestCounter.Increment
+      firstResult.state shouldBe TestCounter.RealState(1)
+
+      val secondResult = testKit.runCommand(TestCounter.Increment)
+      secondResult.stateOfType[TestCounter.RealState].value shouldBe 2
+      // #basic-test
+
+      intercept[AssertionError] {
+        secondResult.stateOfType[TestCounter.NotSerializableState]
+      }.getMessage should include("Expected state class")
+    }
+
+    "run commands with replies" in {
+      val testKit = createTestKit()
+
+      val result = 
testKit.runCommand[Done](TestCounter.IncrementWithConfirmation(_))
+      result.state shouldBe TestCounter.RealState(1)
+      result.reply shouldBe Done
+      result.replyOfType[Done] shouldBe Done
+      result.hasNoReply shouldBe false
+    }
+
+    "detect a missing reply" in {
+      val testKit = createTestKit()
+
+      intercept[AssertionError] {
+        testKit.runCommand[Done](_ => TestCounter.Increment)
+      }.getMessage should include("Missing expected reply")
+    }
+
+    "expose the current state" in {
+      val testKit = createTestKit()
+
+      testKit.getState() shouldBe TestCounter.RealState(0)
+      testKit.runCommand(TestCounter.Increment)
+      testKit.getState() shouldBe TestCounter.RealState(1)
+    }
+
+    "handle a null empty state" in {
+      val testKit = createTestKit(emptyState = null)
+
+      testKit.getState() shouldBe null
+      testKit.runCommand(TestCounter.Increment).state shouldBe 
TestCounter.RealState(1)
+    }
+
+    "recover state when restarted" in {
+      val testKit = createTestKit()
+
+      testKit.runCommand(TestCounter.Increment)
+      testKit.runCommand(TestCounter.Increment)
+
+      testKit.restart().state shouldBe TestCounter.RealState(2)
+      testKit.getState() shouldBe TestCounter.RealState(2)
+    }
+
+    "clear the state and restart" in {
+      val testKit = createTestKit()
+
+      testKit.runCommand(TestCounter.Increment)
+      testKit.clear()
+
+      testKit.getState() shouldBe TestCounter.RealState(0)
+    }
+
+    "start with an empty durable state store" in {
+      val firstTestKit =
+        DurableStateBehaviorTestKit[TestCounter.Command, TestCounter.State](
+          system,
+          TestCounter(persistenceId))
+      firstTestKit.runCommand(TestCounter.Increment).state shouldBe 
TestCounter.RealState(1)
+
+      val secondTestKit =
+        DurableStateBehaviorTestKit[TestCounter.Command, TestCounter.State](
+          system,
+          TestCounter(persistenceId))
+      secondTestKit.getState() shouldBe TestCounter.RealState(0)
+    }
+
+    "detect a non-serializable command" in {
+      val testKit = createTestKit()
+
+      val exception = intercept[IllegalArgumentException] {
+        testKit.runCommand(TestCounter.NotSerializableCommand)
+      }
+      exception.getMessage should include("Command")
+      exception.getCause.getClass shouldBe 
classOf[DisabledJavaSerializer.JavaSerializationException]
+    }
+
+    "detect a non-serializable state" in {
+      val testKit = createTestKit()
+
+      val exception = intercept[IllegalArgumentException] {
+        testKit.runCommand(TestCounter.PersistNotSerializableState)
+      }
+      exception.getMessage should include("State")
+      exception.getCause.getClass shouldBe 
classOf[DisabledJavaSerializer.JavaSerializationException]
+    }
+
+    "detect a non-serializable empty state" in {
+      val testKit = createTestKit(TestCounter.NotSerializableState(0))
+
+      val exception = intercept[IllegalArgumentException] {
+        testKit.runCommand(TestCounter.Increment)
+      }
+      exception.getMessage should include("Empty State")
+      exception.getCause.getClass shouldBe 
classOf[DisabledJavaSerializer.JavaSerializationException]
+    }
+
+    "detect a non-serializable reply" in {
+      val testKit = createTestKit()
+
+      val exception = intercept[IllegalArgumentException] {
+        testKit.runCommand(TestCounter.ReplyNotSerializable(_))
+      }
+      exception.getMessage should include("Reply")
+      exception.getCause.getClass shouldBe classOf[NotSerializableException]
+    }
+
+    "allow serialization verification to be disabled" in {
+      val testKit = createTestKit(
+        settings = DurableStateBehaviorTestKit.SerializationSettings.disabled)
+
+      testKit.runCommand(TestCounter.NotSerializableCommand).state shouldBe 
TestCounter.RealState(0)
+    }
+
+    "only allow DurableStateBehavior" in {
+      intercept[IllegalArgumentException] {
+        DurableStateBehaviorTestKit[TestCounter.Command, TestCounter.State](
+          system,
+          Behaviors.empty[TestCounter.Command])
+      }
+    }
+
+    "support a catch-all signal handler" in {
+      val behavior = TestCounter(persistenceId).receiveSignal {
+        case (_, _) => ()
+      }
+
+      val testKit =
+        DurableStateBehaviorTestKit[TestCounter.Command, 
TestCounter.State](system, behavior)
+
+      testKit.runCommand(TestCounter.Increment).state shouldBe 
TestCounter.RealState(1)
+    }
+  }
+}
diff --git 
a/persistence-typed/src/main/scala/org/apache/pekko/persistence/typed/state/internal/DurableStateBehaviorImpl.scala
 
b/persistence-typed/src/main/scala/org/apache/pekko/persistence/typed/state/internal/DurableStateBehaviorImpl.scala
index bb6662d07d..a56a22b38f 100644
--- 
a/persistence-typed/src/main/scala/org/apache/pekko/persistence/typed/state/internal/DurableStateBehaviorImpl.scala
+++ 
b/persistence-typed/src/main/scala/org/apache/pekko/persistence/typed/state/internal/DurableStateBehaviorImpl.scala
@@ -35,10 +35,10 @@ import pekko.persistence.typed.PersistenceId
 import pekko.persistence.typed.SnapshotAdapter
 import pekko.persistence.typed.state.scaladsl._
 
-import com.typesafe.config.Config
-
 import org.slf4j.LoggerFactory
 
+import com.typesafe.config.Config
+
 @InternalApi
 private[pekko] object DurableStateBehaviorImpl {
 
@@ -48,11 +48,24 @@ private[pekko] object DurableStateBehaviorImpl {
   final case class GetPersistenceId(replyTo: ActorRef[PersistenceId]) extends 
Signal
 
   /**
-   * Used by DurableStateBehaviorTestKit to retrieve the state.
+   * Used by DurableStateBehaviorTestKit to retrieve the `persistenceId` 
without invoking user signal handlers.
+   */
+  final case class GetPersistenceIdForTestKit(replyTo: 
ActorRef[PersistenceId]) extends InternalProtocol
+
+  /**
+   * Used to retrieve a non-null state.
    * Can't be a Signal because those are not stashed.
    */
   final case class GetState[State](replyTo: ActorRef[State]) extends 
InternalProtocol
 
+  /**
+   * Used by DurableStateBehaviorTestKit to retrieve the state.
+   * The response envelope supports a `null` state.
+   */
+  final case class GetStateWithReply[State](replyTo: 
ActorRef[GetStateReply[State]]) extends InternalProtocol
+
+  final case class GetStateReply[State](currentState: State)
+
 }
 
 @InternalApi
diff --git 
a/persistence-typed/src/main/scala/org/apache/pekko/persistence/typed/state/internal/Recovering.scala
 
b/persistence-typed/src/main/scala/org/apache/pekko/persistence/typed/state/internal/Recovering.scala
index a81a666899..89d90e0390 100644
--- 
a/persistence-typed/src/main/scala/org/apache/pekko/persistence/typed/state/internal/Recovering.scala
+++ 
b/persistence-typed/src/main/scala/org/apache/pekko/persistence/typed/state/internal/Recovering.scala
@@ -30,7 +30,11 @@ import pekko.persistence._
 import pekko.persistence.state.scaladsl.GetObjectResult
 import pekko.persistence.typed.state.RecoveryCompleted
 import pekko.persistence.typed.state.RecoveryFailed
-import pekko.persistence.typed.state.internal.DurableStateBehaviorImpl.GetState
+import pekko.persistence.typed.state.internal.DurableStateBehaviorImpl.{
+  GetPersistenceIdForTestKit,
+  GetState,
+  GetStateWithReply
+}
 import pekko.persistence.typed.state.internal.Running.WithRevisionAccessible
 import pekko.util.PrettyDuration._
 
@@ -94,12 +98,14 @@ private[pekko] class Recovering[C, S](
           Behaviors.unhandled
         } else
           onCommand(cmd)
-      case get: GetState[S @unchecked] => stashInternal(get)
-      case RecoveryPermitGranted       => Behaviors.unhandled // should not 
happen, we already have the permit
-      case UpsertSuccess               => Behaviors.unhandled
-      case _: UpsertFailure            => Behaviors.unhandled
-      case DeleteSuccess               => Behaviors.unhandled
-      case _: DeleteFailure            => Behaviors.unhandled
+      case get: GetPersistenceIdForTestKit      => stashInternal(get)
+      case get: GetState[S @unchecked]          => stashInternal(get)
+      case get: GetStateWithReply[S @unchecked] => stashInternal(get)
+      case RecoveryPermitGranted                => Behaviors.unhandled // 
should not happen, we already have the permit
+      case UpsertSuccess                        => Behaviors.unhandled
+      case _: UpsertFailure                     => Behaviors.unhandled
+      case DeleteSuccess                        => Behaviors.unhandled
+      case _: DeleteFailure                     => Behaviors.unhandled
     }
   }
 
diff --git 
a/persistence-typed/src/main/scala/org/apache/pekko/persistence/typed/state/internal/Running.scala
 
b/persistence-typed/src/main/scala/org/apache/pekko/persistence/typed/state/internal/Running.scala
index 7d8d005acf..1371ce3515 100644
--- 
a/persistence-typed/src/main/scala/org/apache/pekko/persistence/typed/state/internal/Running.scala
+++ 
b/persistence-typed/src/main/scala/org/apache/pekko/persistence/typed/state/internal/Running.scala
@@ -28,7 +28,12 @@ import pekko.actor.typed.scaladsl.Behaviors
 import pekko.actor.typed.scaladsl.LoggerOps
 import pekko.annotation.InternalApi
 import pekko.annotation.InternalStableApi
-import pekko.persistence.typed.state.internal.DurableStateBehaviorImpl.GetState
+import pekko.persistence.typed.state.internal.DurableStateBehaviorImpl.{
+  GetPersistenceIdForTestKit,
+  GetState,
+  GetStateReply,
+  GetStateWithReply
+}
 import pekko.persistence.typed.state.scaladsl.Effect
 
 /**
@@ -109,8 +114,10 @@ private[pekko] object Running {
         } else {
           onCommand(state, c)
         }
-      case get: GetState[S @unchecked] => onGetState(get)
-      case _                           => Behaviors.unhandled
+      case get: GetPersistenceIdForTestKit      => onGetPersistenceId(get)
+      case get: GetState[S @unchecked]          => onGetState(get)
+      case get: GetStateWithReply[S @unchecked] => onGetStateWithReply(get)
+      case _                                    => Behaviors.unhandled
     }
 
     override def onSignal: PartialFunction[Signal, Behavior[InternalProtocol]] 
= {
@@ -174,12 +181,23 @@ private[pekko] object Running {
       }
     }
 
-    // Used by DurableStateBehaviorTestKit to retrieve the state.
+    // Used by DurableStateBehaviorTestKit to retrieve the persistence id.
+    def onGetPersistenceId(get: GetPersistenceIdForTestKit): 
Behavior[InternalProtocol] = {
+      get.replyTo ! setup.persistenceId
+      this
+    }
+
     def onGetState(get: GetState[S]): Behavior[InternalProtocol] = {
       get.replyTo ! state.state
       this
     }
 
+    // Used by DurableStateBehaviorTestKit to retrieve the state.
+    def onGetStateWithReply(get: GetStateWithReply[S]): 
Behavior[InternalProtocol] = {
+      get.replyTo ! GetStateReply(state.state)
+      this
+    }
+
     private def handlePersist(
         newState: S,
         cmd: Any,
@@ -276,16 +294,18 @@ private[pekko] object Running {
 
     override def onMessage(msg: InternalProtocol): Behavior[InternalProtocol] 
= {
       msg match {
-        case UpsertSuccess                     => onUpsertSuccess()
-        case UpsertFailure(exc)                => onUpsertFailed(exc)
-        case in: IncomingCommand[C @unchecked] => onCommand(in)
-        case get: GetState[S @unchecked]       => stashInternal(get)
-        case DeleteSuccess                     => onDeleteSuccess()
-        case DeleteFailure(exc)                => onDeleteFailed(exc)
-        case RecoveryTimeout                   => Behaviors.unhandled
-        case RecoveryPermitGranted             => Behaviors.unhandled
-        case _: GetSuccess[?]                  => Behaviors.unhandled
-        case _: GetFailure                     => Behaviors.unhandled
+        case UpsertSuccess                        => onUpsertSuccess()
+        case UpsertFailure(exc)                   => onUpsertFailed(exc)
+        case in: IncomingCommand[C @unchecked]    => onCommand(in)
+        case get: GetPersistenceIdForTestKit      => stashInternal(get)
+        case get: GetState[S @unchecked]          => stashInternal(get)
+        case get: GetStateWithReply[S @unchecked] => stashInternal(get)
+        case DeleteSuccess                        => onDeleteSuccess()
+        case DeleteFailure(exc)                   => onDeleteFailed(exc)
+        case RecoveryTimeout                      => Behaviors.unhandled
+        case RecoveryPermitGranted                => Behaviors.unhandled
+        case _: GetSuccess[?]                     => Behaviors.unhandled
+        case _: GetFailure                        => Behaviors.unhandled
       }
     }
 


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to