diff --git a/jvm/src/main/scala/cs214/webapp/server/web/WebServerRoutes.scala b/jvm/src/main/scala/cs214/webapp/server/web/WebServerRoutes.scala
index a98eb3caf6fba0f730324b947bd5460660167506..24ab94798bf936515c377d53553b1ed79a1624fa 100644
--- a/jvm/src/main/scala/cs214/webapp/server/web/WebServerRoutes.scala
+++ b/jvm/src/main/scala/cs214/webapp/server/web/WebServerRoutes.scala
@@ -82,3 +82,6 @@ private[server] final case class WebServerRoutes()(using cc: castor.Context, log
       case None => cask.Response(f"Unknown instance '$instanceId'", 400)
 
   initialize()
+
+private[webapp] def allRoutes(using cc: castor.Context, log: cask.Logger): Seq[cask.Routes] =
+  Seq(cs214.webapp.server.web.WebServerRoutes())
diff --git a/jvm/src/test/scala/cs214/webapp/AppSuite.scala b/jvm/src/test/scala/cs214/webapp/AppSuite.scala
new file mode 100644
index 0000000000000000000000000000000000000000..7e16f45eb23bc15abbb13f453da5cc450530ee0c
--- /dev/null
+++ b/jvm/src/test/scala/cs214/webapp/AppSuite.scala
@@ -0,0 +1,62 @@
+package cs214.webapp
+package server
+package web
+
+import scala.util.{Try, Success, Failure}
+import scala.concurrent.duration.Duration
+import scala.concurrent.{Await, Future}
+import scala.concurrent.ExecutionContext.Implicits.global
+
+import ujson.Value
+import sttp.ws.WebSocket
+import sttp.client4.*
+import sttp.client4.ws.sync.*
+
+import cs214.webapp.utils.{*, given}
+import cs214.webapp.server.StateMachine
+import sttp.client4.ws.SyncWebSocket
+
+object PingPongSuite:
+  type Ping = String
+  type Pong = String
+  type State = String
+
+  object PingPong extends StateMachine[Ping, State, Pong]:
+    override def appInfo: AppInfo = AppInfo(
+      id = "stress", name = "Stress test", description = "", year = 2024
+    )
+
+    override object wire extends AppWire[Ping, Pong]:
+      override val eventFormat = StringWire
+      override val viewFormat = StringWire
+
+    override def init(clients: Seq[UserId]): String = ""
+    override def project(state: String)(userId: UserId): Pong = state
+    override def transition(state: String)(userId: UserId, event: Ping): Try[Seq[Action[State]]] = Try:
+      Seq(Action.Render(state))
+
+    WebServer.register(PingPong)
+
+abstract class PingPongSuite extends ClientSuite[PingPongSuite.Ping, PingPongSuite.State, PingPongSuite.Pong]:
+  import PingPongSuite.*
+
+  override def app = PingPong
+
+class PagesSuite extends PingPongSuite:
+  import PingPongSuite.*
+  import ClientSuite.*
+
+  test("The main page responds to requests"):
+    withServer: server ?=>
+      assert(quickRequest.get(server.uri).send(backend).code.isSuccess)
+
+  test("The app-selection page responds to requests"):
+    withServer: server ?=>
+      assert(quickRequest.get(uri"$server/app/${PingPong.appInfo.id}").send(backend).code.isSuccess)
+
+  test("Multiple instances can be created at the same time"):
+    withServer:
+      time:
+        for _ <- 1 to 10 do
+          val inst = createInstance(USER_IDS)
+          instanceInfo(inst.instanceId)
diff --git a/jvm/src/test/scala/cs214/webapp/utils/ClientSuite.scala b/jvm/src/test/scala/cs214/webapp/utils/ClientSuite.scala
index e45113107245d15ac1604157759a1d7af5e8a95f..82128e8ab40e3f868baf6decded091e5dacf614f 100644
--- a/jvm/src/test/scala/cs214/webapp/utils/ClientSuite.scala
+++ b/jvm/src/test/scala/cs214/webapp/utils/ClientSuite.scala
@@ -1,58 +1,49 @@
 package cs214.webapp
-package server
-package web
+package utils
 
 import scala.util.Try
 
 import ujson.Value
 import io.undertow.Undertow
-import sttp.client4.quick.*
+
 import sttp.model.Uri
+import sttp.client4.DefaultSyncBackend
 
 import cs214.webapp.server.StateMachine
 import cs214.webapp.server.web.WebServer
 
-import scala.language.implicitConversions
-given unsafeURI: Conversion[String, Uri] with
-  def apply(uri: String): Uri = Uri.unsafeParse(uri)
-
-case class WebServerInfo(uri: String):
-  override def toString = uri
-
-object ServerSuite:
-  type Ping = String
-  type Pong = String
-  type State = String
+extension (sc: StringContext)
+  def uri(args: Any*): Uri =
+    Uri.unsafeParse(sc.s(args*))
 
-  object PingPong extends StateMachine[Ping, State, Pong]:
-    override def appInfo: AppInfo = AppInfo(
-      id = "stress", name = "Stress test", description = "", year = 2024
-    )
+case class WebServerInfo(host: String, port: Int):
+  val uri = uri"http://$host:$port"
+  def authority = f"$host:$port"
+  override def toString = uri.toString
 
-    override object wire extends AppWire[Ping, Pong]:
-      override val eventFormat = StringWire
-      override val viewFormat = StringWire
+object ClientSuite:
+  val HOST = "localhost"
+  val PORT = 8091
+  private val lock = Object() // Run with a single server at a time
 
-    override def init(clients: Seq[UserId]): String = ""
-    override def project(state: String)(userId: UserId): Pong = state
-    override def transition(state: String)(userId: UserId, event: Ping): Try[Seq[Action[State]]] = Try:
-      Seq(Action.Render(state))
+  val backend = DefaultSyncBackend()
+  cask.main.Main.silenceJboss() // Reduce logging
 
-  WebServer.register(PingPong)
-
-  def withServer[T](body: WebServerInfo ?=> T): T =
+  def withServer[T](body: WebServerInfo ?=> T): T = lock.synchronized:
     object caskServer extends cask.Main:
-      def allRoutes = Seq(WebServerRoutes())
+      def allRoutes = cs214.webapp.server.web.allRoutes
 
-    val (address, port) = ("localhost", 8091)
     val server = Undertow.builder
-      .addHttpListener(port, address)
+      .addHttpListener(PORT, HOST)
       .setHandler(caskServer.defaultHandler)
       .build
+    // println(f"[test] Starting server on $HOST:$PORT")
     server.start()
     val t =
-      try body(using WebServerInfo(f"http://${address}:${port}"))
-      finally server.stop()
+      try body(using WebServerInfo(HOST, PORT))
+      finally
+        // println(f"[test] Stopping server on $HOST:$PORT")
+        server.stop()
     t
 
   def time[T](t: => T) =
@@ -62,36 +53,49 @@ object ServerSuite:
     println(f"Elapsed: ${(end - start) / 1000000} ms")
     result
 
-class ServerSuite extends munit.FunSuite:
+  def substituteInstanceInfo(
+    endpointTemplate: String,
+    protocolInfo: ProtocolInfo, userId: UserId
+  )(using server: WebServerInfo): String =
+    endpointTemplate
+      .replace("{{protocol}}", protocolInfo.proto(tls = false))
+      .replace("{{authority}}", f"${server.authority}")
+      .replace("{{userId}}", java.net.URLEncoder.encode(userId, "UTF-8"))
+
+abstract class ClientSuite[Event, State, View] extends munit.FunSuite:
   import sttp.client4.quick.*
-  import ServerSuite.*
 
-  test("The main and app-selection pages respond to requests"):
-    withServer: ws ?=>
-      assert(quickRequest.get(ws.uri).send().code.isSuccess)
-      assert(quickRequest.get(f"$ws/app/${PingPong.appInfo.id}").send().code.isSuccess)
+  /** System under test. **/
+  def app: StateMachine[Event, State, View]
+
+  protected val UID0: String = "yak"
+  protected val UID1: String = "hut"
+  protected val UID2: String = "kik"
 
-  def createInstance(using ws: WebServerInfo) =
-    val users = Seq("yak", "hut", "kik")
+  /** Mock user IDs that can be used in tests */
+  protected val USER_IDS = Seq(UID0, UID1, UID2)
+
+  extension[T] (r: sttp.client4.Response[T])
+    def assertSuccess() =
+      assert(r.code.isSuccess, f"Request failed: ${r.body}")
+      r
+
+  def createInstance(userIds: Seq[UserId])(using ws: WebServerInfo) =
     val js = CreateInstanceRequest.Wire.encode:
-      CreateInstanceRequest(PingPong.appInfo.id, users)
+      CreateInstanceRequest(app.appInfo.id, userIds)
     val jsResponse = quickRequest
-      .post(f"$ws${Endpoints.Api.createInstance}")
+      .post(uri"$ws${Endpoints.Api.createInstance}")
       .header("Content-Type", "application/json")
       .body(ujson.write(js))
       .send()
+      .assertSuccess()
       .body
     CreateInstanceResponse.Wire.decode(ujson.read(jsResponse)).get
 
   def instanceInfo(instanceId: String)(using ws: WebServerInfo) =
-    val resp = InstanceInfoResponse.Wire.decode:
-      ujson.read:
-        quickRequest.get(f"$ws${Endpoints.Api.instanceInfo}?instanceId=$instanceId").send().body
-    resp.get
-
-  test("Multiple instances can be created at the same time"):
-    withServer:
-      time:
-        for _ <- 1 to 10 do
-          val inst = createInstance
-          instanceInfo(inst.instanceId)
+    val jsResponse = quickRequest
+      .get(uri"$ws${Endpoints.Api.instanceInfo}?instanceId=$instanceId")
+      .send()
+      .assertSuccess()
+      .body
+    InstanceInfoResponse.Wire.decode(ujson.read(jsResponse)).get