summaryrefslogtreecommitdiff
path: root/odcsim-testkit
diff options
context:
space:
mode:
authorFabian Mastenbroek <mail.fabianm@gmail.com>2019-04-29 14:56:51 +0200
committerFabian Mastenbroek <mail.fabianm@gmail.com>2019-05-14 12:55:55 +0200
commit7428262dcd8da85de0adca0ef82c57398cf411fc (patch)
treec38e8bcaf2a47fda8d57bbb402608e9462041e0d /odcsim-testkit
parent0f71f1e1c559bfebc20dad4ae979e7f6b58b7acf (diff)
feat: Add testkit for testing behavior
This change adds a testkit for synchronously testking Behavior implementations.
Diffstat (limited to 'odcsim-testkit')
-rw-r--r--odcsim-testkit/build.gradle.kts43
-rw-r--r--odcsim-testkit/src/main/kotlin/com/atlarge/odcsim/testkit/BehaviorTestKit.kt138
-rw-r--r--odcsim-testkit/src/main/kotlin/com/atlarge/odcsim/testkit/TestInbox.kt90
-rw-r--r--odcsim-testkit/src/main/kotlin/com/atlarge/odcsim/testkit/internal/ActorContextStub.kt89
-rw-r--r--odcsim-testkit/src/main/kotlin/com/atlarge/odcsim/testkit/internal/ActorSystemStub.kt52
-rw-r--r--odcsim-testkit/src/main/kotlin/com/atlarge/odcsim/testkit/internal/BehaviorTestKitImpl.kt110
-rw-r--r--odcsim-testkit/src/main/kotlin/com/atlarge/odcsim/testkit/internal/TestInboxImpl.kt99
7 files changed, 621 insertions, 0 deletions
diff --git a/odcsim-testkit/build.gradle.kts b/odcsim-testkit/build.gradle.kts
new file mode 100644
index 00000000..8b19019a
--- /dev/null
+++ b/odcsim-testkit/build.gradle.kts
@@ -0,0 +1,43 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2019 atlarge-research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+/* Build configuration */
+apply(from = "../gradle/kotlin.gradle")
+plugins {
+ `java-library`
+}
+
+/* Project configuration */
+repositories {
+ jcenter()
+}
+
+val junitJupiterVersion: String by extra
+
+dependencies {
+ api(project(":odcsim-core"))
+
+ implementation(kotlin("stdlib"))
+ implementation("org.junit.jupiter:junit-jupiter-api:$junitJupiterVersion")
+}
diff --git a/odcsim-testkit/src/main/kotlin/com/atlarge/odcsim/testkit/BehaviorTestKit.kt b/odcsim-testkit/src/main/kotlin/com/atlarge/odcsim/testkit/BehaviorTestKit.kt
new file mode 100644
index 00000000..65782d52
--- /dev/null
+++ b/odcsim-testkit/src/main/kotlin/com/atlarge/odcsim/testkit/BehaviorTestKit.kt
@@ -0,0 +1,138 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2019 atlarge-research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package com.atlarge.odcsim.testkit
+
+import com.atlarge.odcsim.ActorContext
+import com.atlarge.odcsim.ActorPath
+import com.atlarge.odcsim.ActorRef
+import com.atlarge.odcsim.Behavior
+import com.atlarge.odcsim.Instant
+import com.atlarge.odcsim.isAlive
+import com.atlarge.odcsim.testkit.internal.BehaviorTestKitImpl
+import java.util.UUID
+
+/**
+ * Utensils for synchronous testing of [Behavior] instances.
+ *
+ * @param T The shape of the messages the behavior accepts.
+ */
+interface BehaviorTestKit<T : Any> {
+ /**
+ * The current point in simulation time.
+ */
+ val time: Instant
+
+ /**
+ * The current behavior of the simulated actor.
+ */
+ val behavior: Behavior<T>
+
+ /**
+ * The self reference to the simulated actor inside the test kit.
+ */
+ val ref: ActorRef<T>
+
+ /**
+ * The context of the simulated actor.
+ */
+ val context: ActorContext<T>
+
+ /**
+ * The inbox of the simulated actor.
+ */
+ val inbox: TestInbox<T>
+
+ /**
+ * A flag indicating whether the [Behavior] instance is still alive.
+ */
+ val isAlive: Boolean get() = behavior.isAlive
+
+ /**
+ * Interpret the specified message at the current point in simulation time using the current behavior.
+ *
+ * @return `true` if the message was handled by the behavior, `false` if it was unhandled.
+ */
+ fun run(msg: T): Boolean
+
+ /**
+ * Interpret the oldest message in the inbox using the current behavior.
+ *
+ * @return `true` if the message was handled by the behavior, `false` if it was unhandled.
+ * @throws NoSuchElementException if the actor's inbox is empty.
+ */
+ fun runOne(): Boolean
+
+ /**
+ * Interpret the messages in the inbox until the specified point in simulation time is reached.
+ *
+ * @param time The time until which the messages in the inbox should be processed.
+ */
+ fun runTo(time: Instant)
+
+ /**
+ * Create an anonymous [TestInbox] for receiving messages.
+ */
+ fun <U : Any> createInbox(): TestInbox<U>
+
+ /**
+ * Get the child inbox for the child with the given name, or fail if there is no child with the given name
+ * spawned
+ */
+ fun <U : Any> childInbox(name: String): TestInbox<U>
+
+ /**
+ * Get the child inbox for the child referenced by [ref], or fail if it is not a child of this behavior.
+ */
+ fun <U : Any> childInbox(ref: ActorRef<U>): TestInbox<U>
+
+ /**
+ * Obtain the [BehaviorTestKit] for the child with the given name, or fail if there is no child with the given
+ * name spawned.
+ */
+ fun <U : Any> childTestKit(name: String): BehaviorTestKit<U>
+
+ /**
+ * Obtain the [BehaviorTestKit] for the given child [ActorRef].
+ */
+ fun <U : Any> childTestKit(ref: ActorRef<U>): BehaviorTestKit<U>
+
+ companion object {
+ /**
+ * Create a [BehaviorTestKit] instance for the specified [Behavior].
+ *
+ * @param behavior The behavior for which a test kit should be created.
+ */
+ operator fun <T : Any> invoke(behavior: Behavior<T>): BehaviorTestKit<T> =
+ BehaviorTestKitImpl(behavior, ActorPath.Root(name = "/" + UUID.randomUUID().toString()))
+
+ /**
+ * Create a [BehaviorTestKit] instance for the specified [Behavior].
+ *
+ * @param behavior The behavior for which a test kit should be created.
+ */
+ @JvmStatic
+ fun <T : Any> create(behavior: Behavior<T>): BehaviorTestKit<T> = invoke(behavior)
+ }
+}
diff --git a/odcsim-testkit/src/main/kotlin/com/atlarge/odcsim/testkit/TestInbox.kt b/odcsim-testkit/src/main/kotlin/com/atlarge/odcsim/testkit/TestInbox.kt
new file mode 100644
index 00000000..de240143
--- /dev/null
+++ b/odcsim-testkit/src/main/kotlin/com/atlarge/odcsim/testkit/TestInbox.kt
@@ -0,0 +1,90 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2019 atlarge-research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package com.atlarge.odcsim.testkit
+
+import com.atlarge.odcsim.ActorRef
+import com.atlarge.odcsim.Envelope
+
+/**
+ * A helper class for testing messages sent to an [ActorRef].
+ *
+ * @param T The shape of the messages the inbox accepts.
+ */
+interface TestInbox<T : Any> {
+ /**
+ * The actor reference of this inbox.
+ */
+ val ref: ActorRef<T>
+
+ /**
+ * A flag to indicate whether the inbox contains any messages.
+ */
+ val hasMessages: Boolean
+
+ /**
+ * Receive the oldest message from the inbox and remove it.
+ *
+ * @return The message that has been received.
+ * @throws NoSuchElementException if the inbox is empty.
+ */
+ fun receiveMessage(): T = receiveEnvelope().message
+
+ /**
+ * Receive the oldest message from the inbox and remove it.
+ *
+ * @return The envelope containing the message that has been received.
+ * @throws NoSuchElementException if the inbox is empty.
+ */
+ fun receiveEnvelope(): Envelope<T>
+
+ /**
+ * Receive all messages from the inbox and empty it.
+ *
+ * @return The list of messages in the inbox.
+ */
+ fun receiveAll(): List<Envelope<T>>
+
+ /**
+ * Clear all messages from the inbox.
+ */
+ fun clear()
+
+ /**
+ * Assert that the oldest message is equal to the [expected] message and remove
+ * it from the inbox.
+ *
+ * @param expected The expected message to be the oldest in the inbox.
+ */
+ fun expectMessage(expected: T)
+
+ /**
+ * Assert that the oldest message is equal to the [expected] message and remove
+ * it from the inbox.
+ *
+ * @param expected The expected message to be the oldest in the inbox.
+ * @param message The failure message to fail with.
+ */
+ fun expectMessage(expected: T, message: String)
+}
diff --git a/odcsim-testkit/src/main/kotlin/com/atlarge/odcsim/testkit/internal/ActorContextStub.kt b/odcsim-testkit/src/main/kotlin/com/atlarge/odcsim/testkit/internal/ActorContextStub.kt
new file mode 100644
index 00000000..d11d47a3
--- /dev/null
+++ b/odcsim-testkit/src/main/kotlin/com/atlarge/odcsim/testkit/internal/ActorContextStub.kt
@@ -0,0 +1,89 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2019 atlarge-research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package com.atlarge.odcsim.testkit.internal
+
+import com.atlarge.odcsim.ActorContext
+import com.atlarge.odcsim.ActorRef
+import com.atlarge.odcsim.ActorSystem
+import com.atlarge.odcsim.Behavior
+import com.atlarge.odcsim.Duration
+import com.atlarge.odcsim.Instant
+import com.atlarge.odcsim.internal.logging.LoggerImpl
+import org.slf4j.Logger
+
+/**
+ * A stubbed [ActorContext] implementation for synchronous behavior testing.
+ *
+ * @property owner The owner of this context.
+ */
+internal class ActorContextStub<T : Any>(private val owner: BehaviorTestKitImpl<T>) : ActorContext<T> {
+ /**
+ * The children of this context.
+ */
+ val children = HashMap<String, BehaviorTestKitImpl<*>>()
+
+ override val self: ActorRef<T>
+ get() = owner.ref
+
+ override val time: Instant
+ get() = owner.time
+
+ override val system: ActorSystem<*> by lazy {
+ ActorSystemStub(owner)
+ }
+
+ override val log: Logger by lazy {
+ LoggerImpl(this)
+ }
+
+ override fun <U : Any> send(ref: ActorRef<U>, msg: U, after: Duration) {
+ if (ref !is TestInboxImpl.ActorRefImpl) {
+ throw IllegalArgumentException("The referenced ActorRef is not part of the test kit")
+ }
+
+ ref.send(msg, after)
+ }
+
+ override fun <U : Any> spawn(behavior: Behavior<U>, name: String): ActorRef<U> {
+ val btk = BehaviorTestKitImpl(behavior, self.path.child(name))
+ children[name] = btk
+ return btk.ref
+ }
+
+ override fun stop(child: ActorRef<*>): Boolean {
+ if (child.path.parent != self.path) {
+ // This is not a child of this actor
+ return false
+ }
+ children -= child.path.name
+ return true
+ }
+
+ override fun sync(target: ActorRef<*>) {}
+
+ override fun unsync(target: ActorRef<*>) {}
+
+ override fun isSync(target: ActorRef<*>): Boolean = true
+}
diff --git a/odcsim-testkit/src/main/kotlin/com/atlarge/odcsim/testkit/internal/ActorSystemStub.kt b/odcsim-testkit/src/main/kotlin/com/atlarge/odcsim/testkit/internal/ActorSystemStub.kt
new file mode 100644
index 00000000..f61c1d76
--- /dev/null
+++ b/odcsim-testkit/src/main/kotlin/com/atlarge/odcsim/testkit/internal/ActorSystemStub.kt
@@ -0,0 +1,52 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2019 atlarge-research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package com.atlarge.odcsim.testkit.internal
+
+import com.atlarge.odcsim.ActorPath
+import com.atlarge.odcsim.ActorSystem
+import com.atlarge.odcsim.Duration
+import com.atlarge.odcsim.Instant
+
+/**
+ * A stubbed [ActorSystem] for synchronous testing of behavior.
+ *
+ * @property owner The owner of this actor system.
+ */
+internal class ActorSystemStub<T : Any>(private val owner: BehaviorTestKitImpl<T>) : ActorSystem<T> {
+ override val time: Instant
+ get() = owner.time
+
+ override val name: String
+ get() = owner.ref.path.name
+
+ override fun run(until: Duration) = throw IllegalStateException("Cannot run ActorSystem within actor")
+
+ override fun send(msg: T, after: Duration) = owner.context.send(owner.context.self, msg, after)
+
+ override fun terminate() {}
+
+ override val path: ActorPath
+ get() = owner.ref.path
+}
diff --git a/odcsim-testkit/src/main/kotlin/com/atlarge/odcsim/testkit/internal/BehaviorTestKitImpl.kt b/odcsim-testkit/src/main/kotlin/com/atlarge/odcsim/testkit/internal/BehaviorTestKitImpl.kt
new file mode 100644
index 00000000..5b6669bb
--- /dev/null
+++ b/odcsim-testkit/src/main/kotlin/com/atlarge/odcsim/testkit/internal/BehaviorTestKitImpl.kt
@@ -0,0 +1,110 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2019 atlarge-research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package com.atlarge.odcsim.testkit.internal
+
+import com.atlarge.odcsim.ActorPath
+import com.atlarge.odcsim.ActorRef
+import com.atlarge.odcsim.Behavior
+import com.atlarge.odcsim.Instant
+import com.atlarge.odcsim.internal.BehaviorInterpreter
+import com.atlarge.odcsim.testkit.BehaviorTestKit
+import com.atlarge.odcsim.testkit.TestInbox
+import java.util.UUID
+import kotlin.math.max
+
+/**
+ * Default implementation of the [BehaviorTestKit] interface.
+ *
+ * @param initialBehavior The initial behavior to initialize the actor of the test kit with.
+ * @param path The path to the actor.
+ */
+internal class BehaviorTestKitImpl<T : Any>(initialBehavior: Behavior<T>,
+ path: ActorPath) : BehaviorTestKit<T> {
+ /**
+ * The [BehaviorInterpreter] used to interpret incoming messages.
+ */
+ private val interpreter = BehaviorInterpreter(initialBehavior)
+
+ /**
+ * A flag to indicate whether the behavior was initially started.
+ */
+ private var isStarted: Boolean = false
+
+ override var time: Instant = .0
+ private set
+
+ override val inbox: TestInbox<T> = TestInboxImpl(this, path)
+
+ override val behavior: Behavior<T> get() = interpreter.behavior
+
+ override val ref: ActorRef<T> = inbox.ref
+
+ override val context: ActorContextStub<T> = ActorContextStub(this)
+
+ override fun run(msg: T): Boolean {
+ if (!isStarted) {
+ isStarted = true
+ interpreter.start(context)
+ }
+
+ return interpreter.interpretMessage(context, msg)
+ }
+
+ override fun runOne(): Boolean {
+ val (delivery, msg) = inbox.receiveEnvelope()
+ time = max(time, delivery)
+ return run(msg)
+ }
+
+ override fun runTo(time: Instant) {
+ while (inbox.hasMessages && this.time <= time) {
+ runOne()
+ }
+ this.time = time
+ }
+
+ override fun <U : Any> createInbox(): TestInbox<U> {
+ return TestInboxImpl(this, ref.path.child(UUID.randomUUID().toString()))
+ }
+
+ override fun <U : Any> childInbox(name: String): TestInbox<U> = childTestKit<U>(name).inbox
+
+ override fun <U : Any> childInbox(ref: ActorRef<U>): TestInbox<U> = childTestKit(ref).inbox
+
+ override fun <U : Any> childTestKit(name: String): BehaviorTestKit<U> {
+ @Suppress("UNCHECKED_CAST")
+ return context.children[ref.path.name] as BehaviorTestKitImpl<U>? ?: throw IllegalArgumentException("$ref is not a child of $this")
+ }
+
+ override fun <U : Any> childTestKit(ref: ActorRef<U>): BehaviorTestKit<U> {
+ val btk = childTestKit<U>(ref.path.name)
+
+ if (btk.ref != ref) {
+ throw IllegalArgumentException("$ref is not a child of $this")
+ }
+
+ return btk
+ }
+}
diff --git a/odcsim-testkit/src/main/kotlin/com/atlarge/odcsim/testkit/internal/TestInboxImpl.kt b/odcsim-testkit/src/main/kotlin/com/atlarge/odcsim/testkit/internal/TestInboxImpl.kt
new file mode 100644
index 00000000..2f939eaa
--- /dev/null
+++ b/odcsim-testkit/src/main/kotlin/com/atlarge/odcsim/testkit/internal/TestInboxImpl.kt
@@ -0,0 +1,99 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2019 atlarge-research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package com.atlarge.odcsim.testkit.internal
+
+import com.atlarge.odcsim.ActorPath
+import com.atlarge.odcsim.ActorRef
+import com.atlarge.odcsim.Envelope
+import com.atlarge.odcsim.Instant
+import com.atlarge.odcsim.testkit.TestInbox
+import org.junit.jupiter.api.Assertions.assertEquals
+import java.util.PriorityQueue
+
+/**
+ * A helper class for testing messages sent to an [ActorRef].
+ *
+ * @param owner The owner of the inbox.
+ * @param path The path to the test inbox.
+ * @param T The shape of the messages the inbox accepts.
+ */
+internal class TestInboxImpl<T : Any>(private val owner: BehaviorTestKitImpl<*>, path: ActorPath) : TestInbox<T> {
+ /**
+ * The queue of received messages.
+ */
+ private val inbox = PriorityQueue<Envelope<T>>()
+
+ /**
+ * The identifier for the next message to be scheduled.
+ */
+ private var nextId: Long = 0
+
+ override val ref: ActorRef<T> = ActorRefImpl(path)
+
+ override val hasMessages: Boolean
+ get() = inbox.isNotEmpty()
+
+ override fun receiveEnvelope(): Envelope<T> = inbox.remove()
+
+ override fun receiveAll(): List<Envelope<T>> = inbox.toList().also { inbox.clear() }
+
+ override fun clear() = inbox.clear()
+
+ override fun expectMessage(expected: T) = assertEquals(expected, receiveMessage())
+
+ override fun expectMessage(expected: T, message: String) = assertEquals(expected, receiveMessage(), message)
+
+ internal inner class ActorRefImpl(override val path: ActorPath) : ActorRef<T> {
+ /**
+ * Send the specified message to the actor this reference is pointing to after the specified delay.
+ *
+ * @param msg The message to send.
+ * @param after The delay before the message is received.
+ */
+ fun send(msg: T, after: Instant) {
+ inbox.add(EnvelopeImpl(nextId++, owner.time + after, msg))
+ }
+ }
+
+ /**
+ * A wrapper around a message that has been scheduled for processing.
+ *
+ * @property id The identifier of the message to keep the priority queue stable.
+ * @property time The point in time to deliver the message.
+ * @property message The message to wrap.
+ */
+ private inner class EnvelopeImpl(val id: Long,
+ override val time: Instant,
+ override val message: T) : Envelope<T> {
+ override fun compareTo(other: Envelope<*>): Int {
+ val cmp = super.compareTo(other)
+ return if (cmp == 0 && other is EnvelopeImpl)
+ id.compareTo(other.id)
+ else
+ cmp
+ }
+ }
+
+}