Successfully run an engine with no tests.

This commit is contained in:
Pat Garrity 2025-09-13 10:21:48 -05:00
parent 311ab17d5f
commit 3137fe4005
Signed by: pfm
GPG key ID: 5CA5D21BAB7F3A76
7 changed files with 266 additions and 34 deletions

View file

@ -41,7 +41,7 @@ val Deps = new {
val Gs = new {
val Uuid: ModuleID = "gs" %% "gs-uuid-v0" % "0.4.1"
val Timing: ModuleID = "gs" %% "gs-timing-v0" % "0.1.2"
val Datagen: ModuleID = "gs" %% "gs-datagen-core-v0" % "0.3.1"
val Datagen: ModuleID = "gs" %% "gs-datagen-core-v0" % "0.3.3"
}
val MUnit: ModuleID = "org.scalameta" %% "munit" % "1.1.1"
@ -78,7 +78,8 @@ lazy val `test-support` = project
.settings(
libraryDependencies ++= Seq(
Deps.Cats.Core,
Deps.Cats.Effect
Deps.Cats.Effect,
Deps.Natchez.Core
)
)

View file

@ -2,6 +2,7 @@ package gs.test.v0.reporting
import cats.effect.Async
import cats.effect.Ref
import cats.effect.Resource
import cats.effect.std.Queue
import cats.syntax.all.*
import gs.test.v0.api.GroupResult
@ -10,7 +11,7 @@ import gs.test.v0.api.TestExecution
final class InMemoryReporter[F[_]: Async] private (
suiteExecution: Ref[F, Option[SuiteExecution]],
groupResults: Queue[F, (GroupResult, List[TestExecution])]
groupResults: Queue[F, Option[(GroupResult, List[TestExecution])]]
) extends Reporter[F]:
/** @inheritDocs
@ -22,7 +23,7 @@ final class InMemoryReporter[F[_]: Async] private (
override def reportGroup(
groupResult: GroupResult,
testExecutions: List[TestExecution]
): F[Unit] = groupResults.offer(groupResult -> testExecutions)
): F[Unit] = groupResults.offer(Some(groupResult -> testExecutions))
/** @inheritDocs
*/
@ -31,20 +32,22 @@ final class InMemoryReporter[F[_]: Async] private (
/** @inheritDocs
*/
override def endReport(): F[Unit] = Async[F].unit
override def endReport(): F[Unit] = groupResults.offer(None)
def getSuiteExecution(): F[Option[SuiteExecution]] = suiteExecution.get
// TODO: make stream to consume and reify to list
def getGroupResults(): F[List[(GroupResult, List[TestExecution])]] =
???
def terminateAndGetResults(): F[List[(GroupResult, List[TestExecution])]] =
endReport() *>
fs2.Stream.fromQueueNoneTerminated(groupResults).compile.toList
object InMemoryReporter:
def initialize[F[_]: Async]: F[InMemoryReporter[F]] =
for
se <- Ref.of[F, Option[SuiteExecution]](None)
gr <- Queue.unbounded[F, (GroupResult, List[TestExecution])]
yield new InMemoryReporter(se, gr)
def provision[F[_]: Async]: Resource[F, InMemoryReporter[F]] =
Resource.make(
for
se <- Ref.of[F, Option[SuiteExecution]](None)
gr <- Queue.unbounded[F, Option[(GroupResult, List[TestExecution])]]
yield new InMemoryReporter(se, gr)
)(_ => Async[F].unit)
end InMemoryReporter

View file

@ -1,7 +1,6 @@
package gs.test.v0.runtime.engine
import cats.effect.Async
import cats.effect.Resource
import cats.syntax.all.*
import gs.test.v0.api.GroupResult
import gs.test.v0.api.SuiteExecution
@ -51,8 +50,8 @@ import natchez.Span
*/
final class TestEngine[F[_]: Async](
val configuration: EngineConfiguration,
reporter: Reporter[F],
entryPoint: EntryPoint[F],
val reporter: Reporter[F],
val entryPoint: EntryPoint[F],
timing: Timing[F]
):
@ -310,32 +309,29 @@ final class TestEngine[F[_]: Async](
object TestEngine:
/** Provision a new [[TestEngine]].
/** Initialize a new [[TestEngine]].
*
* @param configuration
* The [[EngineConfiguration]] used for this instance.
* @param reporter
* Resource which manages the [[Reporter]].
* Reports test results.
* @param entryPoint
* Resource which manages the telemetry entry point.
* Entry point for OpenTelemetry support.
* @param timing
* Timing controller.
* @return
* Resource which manages the [[TestEngine]].
*/
def provision[F[_]: Async](
def initialize[F[_]: Async](
configuration: EngineConfiguration,
reporter: Resource[F, Reporter[F]],
entryPoint: Resource[F, EntryPoint[F]],
reporter: Reporter[F],
entryPoint: EntryPoint[F],
timing: Timing[F]
): Resource[F, TestEngine[F]] =
for
r <- reporter
ep <- entryPoint
yield new TestEngine(
): TestEngine[F] =
new TestEngine(
configuration = configuration,
reporter = r,
entryPoint = ep,
reporter = reporter,
entryPoint = entryPoint,
timing = timing
)

View file

@ -0,0 +1,81 @@
package gs.test.v0.runtime.engine
import cats.effect.IO
import cats.effect.Resource
import gs.test.v0.api.TestGroupDefinition
import gs.test.v0.reporting.InMemoryReporter
import gs.test.v0.runtime.engine.TestEngineTests.EngineObservation
import gs.timing.v0.MonotonicProvider.ManualTickProvider
import gs.timing.v0.Timing
import gs.uuid.v0.UUID
import java.time.Clock
import munit.*
import support.*
class TestEngineTests extends IOSuite:
import TestEngineTests.TestData
iotest("should run an engine with no tests") {
newEngine().use { obs =>
for
suiteExecution <- obs.engine.runSuite(
suite = Generators.testSuite(),
tests = emptyStream[TestGroupDefinition[IO]]
)
rootSpan <- obs.entryPoint.getSpan(EngineConstants.Tracing.RootSpan)
results <- obs.reporter.terminateAndGetResults()
yield
assertEquals(rootSpan.isDefined, true)
assertEquals(results.isEmpty, true)
assertEquals(suiteExecution.seen, 0L)
assertEquals(suiteExecution.passed, 0L)
assertEquals(suiteExecution.failed, 0L)
}
}
private def emptyStream[A]: fs2.Stream[IO, A] =
fs2.Stream.empty
private def liftToResource[A](io: IO[A]): Resource[IO, A] =
Resource.make(io)(_ => IO.unit)
def newEngine(): Resource[IO, EngineObservation] =
for
(tickProvider, timing) <- liftToResource(Timing.manual[IO])
reporter <- InMemoryReporter.provision[IO]
entryPoint <- TestEntryPoint.provision()
yield EngineObservation(
tickProvider = tickProvider,
reporter = reporter,
entryPoint = entryPoint,
engine = TestEngine.initialize[IO](
configuration = TestData.Config,
reporter = reporter,
entryPoint = entryPoint,
timing = timing
)
)
object TestEngineTests:
private object TestData:
val Config: EngineConfiguration = EngineConfiguration(
groupConcurrency = ConcurrencySetting.Serial,
testConcurrency = ConcurrencySetting.Serial,
clock = Clock.systemUTC(),
suiteIdGenerator = UUID.Generator.version7,
testIdGenerator = UUID.Generator.version7
)
end TestData
case class EngineObservation(
tickProvider: ManualTickProvider[IO],
reporter: InMemoryReporter[IO],
entryPoint: TestEntryPoint,
engine: TestEngine[IO]
)
end TestEngineTests

View file

@ -64,16 +64,24 @@ object Generators:
)
val GenTestExecution: Gen[TestExecution] =
Gen.boolean().map(passed => InputGenTestExecution.generate(passed))
Gen.boolean().satisfy(InputGenTestExecution)
val GenTestExecutionPassed: Gen[TestExecution] =
Gen.single(true).map(InputGenTestExecution.generate)
InputGenTestExecution.toGen(true)
val GenTestExecutionFailed: Gen[TestExecution] =
Gen.single(false).map(InputGenTestExecution.generate)
InputGenTestExecution.toGen(false)
given Generated[TestExecution] = Generated.of(GenTestExecution)
val GenTestSuite: Gen[TestSuite] =
for
pid <- GenPermanentId
name <- Gen.string.alphaNumeric(Size.fixed(8))
yield TestSuite(pid, name, None)
given Generated[TestSuite] = Generated.of(GenTestSuite)
def testExecutionId(): TestExecution.Id = GenTestExecutionId.gen()
def permanentId(): PermanentId = GenPermanentId.gen()
@ -91,9 +99,11 @@ object Generators:
def testExecution(): TestExecution = GenTestExecution.gen()
def testExecutionPassed(): TestExecution =
InputGenTestExecution.generate(true)
GenTestExecutionPassed.gen()
def testExecutionFailed(): TestExecution =
InputGenTestExecution.generate(false)
GenTestExecutionFailed.gen()
def testSuite(): TestSuite = GenTestSuite.gen()
end Generators

View file

@ -0,0 +1,48 @@
package support
import cats.effect.IO
import cats.effect.kernel.Resource
import cats.effect.std.MapRef
import natchez.EntryPoint
import natchez.Kernel
import natchez.Span
import natchez.Span.Options
final class TestEntryPoint private (
spans: MapRef[IO, String, Option[TestSpan]]
) extends EntryPoint[IO]:
def getSpan(name: String): IO[Option[TestSpan]] =
spans(name).get
override def root(
name: String,
options: Options
): Resource[IO, Span[IO]] =
TestSpan
.provisionRoot(name, spans)
.evalTap(span => spans.setKeyValue(name, span))
override def continue(
name: String,
kernel: Kernel,
options: Options
): Resource[IO, Span[IO]] =
throw new IllegalStateException("Not allowed for testing.")
override def continueOrElseRoot(
name: String,
kernel: Kernel,
options: Options
): Resource[IO, Span[IO]] =
throw new IllegalStateException("Not allowed for testing.")
object TestEntryPoint:
def initialize(): IO[TestEntryPoint] =
MapRef.apply[IO, String, TestSpan].map(spans => new TestEntryPoint(spans))
def provision(): Resource[IO, TestEntryPoint] =
Resource.make(initialize())(_ => IO.unit)
end TestEntryPoint

View file

@ -0,0 +1,93 @@
package support
import cats.effect.IO
import cats.effect.kernel.Resource
import cats.effect.std.MapRef
import java.net.URI
import java.util.UUID
import natchez.Kernel
import natchez.Span
import natchez.Span.Options
import natchez.TraceValue
final class TestSpan private (
val name: String,
val rawTraceId: String,
val rawSpanId: String,
baggage: MapRef[IO, String, Option[TraceValue]],
spans: MapRef[IO, String, Option[TestSpan]]
) extends Span[IO]:
override def put(fields: (String, TraceValue)*): IO[Unit] =
fields.map { case (k, v) => baggage.setKeyValue(k, v) }.sequence.as(())
override def log(fields: (String, TraceValue)*): IO[Unit] = IO.unit
override def log(event: String): IO[Unit] = IO.unit
override def attachError(
err: Throwable,
fields: (String, TraceValue)*
): IO[Unit] = IO.unit
override def kernel: IO[Kernel] = IO(Kernel(Map.empty))
override def span(
name: String,
options: Options
): Resource[IO, Span[IO]] =
TestSpan
.provision(name, rawTraceId, TestSpan.makeSpanId(), spans)
.evalTap(span => spans.setKeyValue(name, span))
override def traceId: IO[Option[String]] = IO(Some(rawTraceId))
override def spanId: IO[Option[String]] = IO(Some(rawSpanId))
override def traceUri: IO[Option[URI]] = IO(None)
object TestSpan:
def initializeRoot(
name: String,
spans: MapRef[IO, String, Option[TestSpan]]
): IO[TestSpan] =
initialize(name, makeTraceId(), makeSpanId(), spans)
def initialize(
name: String,
traceId: String,
spanId: String,
spans: MapRef[IO, String, Option[TestSpan]]
): IO[TestSpan] =
MapRef.apply[IO, String, TraceValue].map { baggage =>
new TestSpan(
name = name,
rawTraceId = traceId,
rawSpanId = spanId,
baggage = baggage,
spans = spans
)
}
def provisionRoot(
name: String,
spans: MapRef[IO, String, Option[TestSpan]]
): Resource[IO, TestSpan] =
provision(name, makeTraceId(), makeSpanId(), spans)
def provision(
name: String,
traceId: String,
spanId: String,
spans: MapRef[IO, String, Option[TestSpan]]
): Resource[IO, TestSpan] =
Resource.make(initialize(name, traceId, spanId, spans))(_ => IO.unit)
private def makeTraceId(): String =
UUID.randomUUID().toString().filterNot(_ == '-')
private def makeSpanId(): String =
makeTraceId().take(16)
end TestSpan