Compare commits

..

6 commits

23 changed files with 877 additions and 188 deletions

View file

@ -1,5 +1,5 @@
// See: https://github.com/scalameta/scalafmt/tags for the latest tags. // See: https://github.com/scalameta/scalafmt/tags for the latest tags.
version = 3.8.1 version = 3.9.9
runner.dialect = scala3 runner.dialect = scala3
maxColumn = 80 maxColumn = 80

View file

@ -1,4 +1,4 @@
val scala3: String = "3.5.1" val scala3: String = "3.7.1"
ThisBuild / scalaVersion := scala3 ThisBuild / scalaVersion := scala3
ThisBuild / versionScheme := Some("semver-spec") ThisBuild / versionScheme := Some("semver-spec")
@ -10,7 +10,7 @@ ThisBuild / externalResolvers := Seq(
) )
ThisBuild / licenses := Seq( ThisBuild / licenses := Seq(
"MIT" -> url("https://garrity.co/MIT.html") "MIT" -> url("https://git.garrity.co/garrity-software/gs-test/LICENSE")
) )
val noPublishSettings = Seq( val noPublishSettings = Seq(
@ -26,25 +26,25 @@ val sharedSettings = Seq(
val Deps = new { val Deps = new {
val Cats = new { val Cats = new {
val Core: ModuleID = "org.typelevel" %% "cats-core" % "2.12.0" val Core: ModuleID = "org.typelevel" %% "cats-core" % "2.13.0"
val Effect: ModuleID = "org.typelevel" %% "cats-effect" % "3.5.4" val Effect: ModuleID = "org.typelevel" %% "cats-effect" % "3.6.3"
} }
val Fs2 = new { val Fs2 = new {
val Core: ModuleID = "co.fs2" %% "fs2-core" % "3.10.2" val Core: ModuleID = "co.fs2" %% "fs2-core" % "3.12.0"
} }
val Natchez = new { val Natchez = new {
val Core: ModuleID = "org.tpolecat" %% "natchez-core" % "0.3.6" val Core: ModuleID = "org.tpolecat" %% "natchez-core" % "0.3.8"
} }
val Gs = new { val Gs = new {
val Uuid: ModuleID = "gs" %% "gs-uuid-v0" % "0.3.0" val Uuid: ModuleID = "gs" %% "gs-uuid-v0" % "0.4.1"
val Timing: ModuleID = "gs" %% "gs-timing-v0" % "0.1.1" val Timing: ModuleID = "gs" %% "gs-timing-v0" % "0.1.2"
val Datagen: ModuleID = "gs" %% "gs-datagen-core-v0" % "0.2.0" val Datagen: ModuleID = "gs" %% "gs-datagen-core-v0" % "0.3.1"
} }
val MUnit: ModuleID = "org.scalameta" %% "munit" % "1.0.1" val MUnit: ModuleID = "org.scalameta" %% "munit" % "1.1.1"
} }
lazy val testSettings = Seq( lazy val testSettings = Seq(
@ -59,11 +59,14 @@ lazy val `gs-test` = project
.aggregate( .aggregate(
`test-support`, `test-support`,
api, api,
reporting,
runtime runtime
) )
.settings(noPublishSettings) .settings(noPublishSettings)
.settings(name := s"${gsProjectName.value}-v${semVerMajor.value}") .settings(name := s"${gsProjectName.value}-v${semVerMajor.value}")
/** Internal project used for unit tests.
*/
lazy val `test-support` = project lazy val `test-support` = project
.in(file("modules/test-support")) .in(file("modules/test-support"))
.settings(sharedSettings) .settings(sharedSettings)
@ -79,6 +82,8 @@ lazy val `test-support` = project
) )
) )
/** Core API - the only dependency needed to write tests.
*/
lazy val api = project lazy val api = project
.in(file("modules/api")) .in(file("modules/api"))
.dependsOn(`test-support` % "test->test") .dependsOn(`test-support` % "test->test")
@ -89,16 +94,36 @@ lazy val api = project
) )
.settings( .settings(
libraryDependencies ++= Seq( libraryDependencies ++= Seq(
Deps.Gs.Uuid,
Deps.Cats.Core, Deps.Cats.Core,
Deps.Cats.Effect, Deps.Cats.Effect,
Deps.Natchez.Core Deps.Natchez.Core
) )
) )
/** Reporting API and implementations.
*/
lazy val reporting = project
.in(file("modules/reporting"))
.dependsOn(`test-support` % "test->test")
.dependsOn(api)
.settings(sharedSettings)
.settings(testSettings)
.settings(
name := s"${gsProjectName.value}-reporting-v${semVerMajor.value}"
)
.settings(
libraryDependencies ++= Seq(
Deps.Fs2.Core
)
)
/** Runtime - the dependency needed to _run_ tests.
*/
lazy val runtime = project lazy val runtime = project
.in(file("modules/runtime")) .in(file("modules/runtime"))
.dependsOn(`test-support` % "test->test") .dependsOn(`test-support` % "test->test")
.dependsOn(api) .dependsOn(api, reporting)
.settings(sharedSettings) .settings(sharedSettings)
.settings(testSettings) .settings(testSettings)
.settings( .settings(
@ -106,7 +131,6 @@ lazy val runtime = project
) )
.settings( .settings(
libraryDependencies ++= Seq( libraryDependencies ++= Seq(
Deps.Gs.Uuid,
Deps.Gs.Timing, Deps.Gs.Timing,
Deps.Cats.Core, Deps.Cats.Core,
Deps.Cats.Effect, Deps.Cats.Effect,

View file

@ -18,7 +18,7 @@ object Check:
*/ */
def apply[A](candidate: A): Check[A] = candidate def apply[A](candidate: A): Check[A] = candidate
extension [A: ClassTag](candidate: Check[A]) extension [A](candidate: Check[A])
/** @return /** @return
* The unwrapped value of this [[Check]]. * The unwrapped value of this [[Check]].
*/ */
@ -42,6 +42,7 @@ object Check:
)( )(
using using
CanEqual[A, A], CanEqual[A, A],
ClassTag[A],
SourcePosition SourcePosition
): TestResult = ): TestResult =
Assertion.IsEqualTo.evaluate(candidate, expected) Assertion.IsEqualTo.evaluate(candidate, expected)
@ -67,6 +68,7 @@ object Check:
)( )(
using using
CanEqual[A, A], CanEqual[A, A],
ClassTag[A],
SourcePosition SourcePosition
): F[TestResult] = ): F[TestResult] =
Sync[F].delay(isEqualTo(expected)) Sync[F].delay(isEqualTo(expected))

View file

@ -0,0 +1,28 @@
package gs.test.v0.api
import scala.concurrent.duration.FiniteDuration
/** Represents the results of executing an entire group of tests.
*
* @param name
* The name of the executed group.
* @param documentation
* The documentation for the group.
* @param duration
* The overall duration of execution.
* @param seen
* The number of tests seen.
* @param passed
* The number of tests which passed.
* @param failed
* The number of tests which failed.
*/
final class GroupResult(
val name: TestGroupDefinition.Name,
val documentation: Option[String],
val duration: FiniteDuration,
val seen: Long,
val passed: Long,
val failed: Long
):
def millis: Long = duration.toMillis

View file

@ -0,0 +1,36 @@
package gs.test.v0.api
import gs.uuid.v0.UUID
import java.time.Instant
import scala.concurrent.duration.FiniteDuration
/** Describes the overall result of execution a suite of tests.
*
* @param id
* Unique identifier for this execution.
* @param testSuite
* Suite-level identifiers and metadata.
* @param traceId
* The 128-bit trace identifier used for this suite.
* @param duration
* Overall amount of time it took to execute the suite.
* @param seen
* Overall number of tests seen.
* @param passed
* Overall number of passed tests.
* @param failed
* Overall number of failed tests.
* @param executedAt
* Timestamp at which this suite was executed.
*/
case class SuiteExecution(
id: UUID,
testSuite: TestSuite,
traceId: String,
duration: FiniteDuration,
seen: Long,
passed: Long,
failed: Long,
executedAt: Instant
):
def millis: Long = duration.toMillis

View file

@ -1,11 +1,6 @@
package gs.test.v0.runtime package gs.test.v0.api
import cats.Show import cats.Show
import gs.test.v0.api.Marker
import gs.test.v0.api.PermanentId
import gs.test.v0.api.SourcePosition
import gs.test.v0.api.Tag
import gs.test.v0.api.TestFailure
import gs.uuid.v0.UUID import gs.uuid.v0.UUID
import scala.concurrent.duration.FiniteDuration import scala.concurrent.duration.FiniteDuration
@ -26,8 +21,8 @@ import scala.concurrent.duration.FiniteDuration
* Markers for the test that was executed. * Markers for the test that was executed.
* @param result * @param result
* The result of the test. * The result of the test.
* @param traceId * @param spanId
* The 128-bit trace identifier used for this test. * The 64-bit span identifier used for this test (including before/after).
* @param sourcePosition * @param sourcePosition
* The position, in source code, of the test that was executed. * The position, in source code, of the test that was executed.
* @param duration * @param duration
@ -41,10 +36,23 @@ case class TestExecution(
tags: List[Tag], tags: List[Tag],
markers: List[Marker], markers: List[Marker],
result: Either[TestFailure, Any], result: Either[TestFailure, Any],
traceId: UUID, spanId: String,
sourcePosition: SourcePosition, sourcePosition: SourcePosition,
duration: FiniteDuration duration: FiniteDuration
) ):
/** @return
* The string "passed" if the test passed, and "failed" otherwise.
*/
def textResult: String = result match {
case Left(_) => "failed"
case Right(_) => "passed"
}
/** @return
* The duration, in milliseconds, it took this test to execute.
*/
def millis: Long = duration.toMillis
object TestExecution: object TestExecution:

View file

@ -2,7 +2,8 @@ package gs.test.v0.api
/** Base trait for all failures recognized by gs-test. /** Base trait for all failures recognized by gs-test.
*/ */
sealed trait TestFailure sealed trait TestFailure:
def message: String
object TestFailure: object TestFailure:
@ -44,6 +45,7 @@ object TestFailure:
*/ */
case class ExceptionThrown( case class ExceptionThrown(
cause: Throwable cause: Throwable
) extends TestFailure ) extends TestFailure:
override def message: String = cause.getMessage()
end TestFailure end TestFailure

View file

@ -1,7 +1,6 @@
package gs.test.v0.api package gs.test.v0.api
import cats.Show import cats.Show
import cats.effect.Async
/** Each group is comprised of a list of [[Test]]. This list may be empty. /** Each group is comprised of a list of [[Test]]. This list may be empty.
* *
@ -18,7 +17,7 @@ import cats.effect.Async
* @param tests * @param tests
* The list of tests in this group. * The list of tests in this group.
*/ */
final class TestGroupDefinition[F[_]: Async]( final class TestGroupDefinition[F[_]](
val name: TestGroupDefinition.Name, val name: TestGroupDefinition.Name,
val documentation: Option[String], val documentation: Option[String],
val testTags: List[Tag], val testTags: List[Tag],

View file

@ -0,0 +1,30 @@
package gs.test.v0.reporting
import cats.Applicative
import gs.test.v0.api.GroupResult
import gs.test.v0.api.SuiteExecution
import gs.test.v0.api.TestExecution
/** No-op implementation of [[Reporter]].
*/
final class NoopReporter[F[_]: Applicative] extends Reporter[F]:
/** @inheritDocs
*/
override def startReport(): F[Unit] = Applicative[F].unit
/** @inheritDocs
*/
override def reportGroup(
groupResult: GroupResult,
testExecutions: List[TestExecution]
): F[Unit] = Applicative[F].unit
/** @inheritDocs
*/
override def reportSuite(suiteExecution: SuiteExecution): F[Unit] =
Applicative[F].unit
/** @inheritDocs
*/
override def endReport(): F[Unit] = Applicative[F].unit

View file

@ -0,0 +1,27 @@
package gs.test.v0.reporting
import gs.test.v0.api.GroupResult
import gs.test.v0.api.SuiteExecution
import gs.test.v0.api.TestExecution
final class NoopResultFormatter extends ResultFormatter:
/** @inheritDocs
*/
override def prefix: String = ""
/** @inheritDocs
*/
override def suffix: String = ""
/** @inheritDocs
*/
override def formatGroupResult(groupResult: GroupResult): String = ""
/** @inheritDocs
*/
override def formatTestExecution(testExecution: TestExecution): String = ""
/** @inheritDocs
*/
override def formatSuiteExecution(suiteExecution: SuiteExecution): String = ""

View file

@ -0,0 +1,118 @@
package gs.test.v0.reporting
import cats.effect.Async
import cats.effect.Concurrent
import cats.effect.Resource
import cats.effect.kernel.Fiber
import cats.effect.std.Queue
import cats.effect.syntax.all.*
import cats.syntax.all.*
import fs2.text
import gs.test.v0.api.GroupResult
import gs.test.v0.api.SuiteExecution
import gs.test.v0.api.TestExecution
import java.io.OutputStream
/** Implementation of [[Reporter]] that writes bytes to an `OutputStream`.
*
* @param formatter
* The [[ResultFormatter]] used to render test results.
* @param state
* The internal state of the reporter.
*/
final class OutputStreamReporter[F[_]: Async] private (
formatter: ResultFormatter,
state: OutputStreamReporter.State[F]
) extends Reporter[F]:
/** @inheritDocs
*/
override def startReport(): F[Unit] =
write(formatter.prefix)
/** @inheritDocs
*/
override def reportGroup(
groupResult: GroupResult,
testExecutions: List[TestExecution]
): F[Unit] =
write(formatter.formatGroupResult(groupResult)) *>
testExecutions
.map(formatter.formatTestExecution)
.map(write)
.sequence
.as(())
/** @inheritDocs
*/
override def reportSuite(suiteExecution: SuiteExecution): F[Unit] =
write(formatter.formatSuiteExecution(suiteExecution))
/** @inheritDocs
*/
override def endReport(): F[Unit] =
write(formatter.suffix)
private def write(output: String): F[Unit] =
state.queue.offer(Some(output))
/** Produce an effect that, when executed, will cause the underlying stream to
* terminate. After executing this effect, the `OutputStreamReporter` will no
* longer be capable of writing more output.
*
* @return
* The effect that describes the stop operation.
*/
def stop(): F[Unit] = state.queue.offer(None)
object OutputStreamReporter:
/** Provision a new [[OutputStreamReporter]].
*
* @param formatter
* The [[ResultFormatter]] this reporter should use to render test results.
* @param output
* Resource which manages the `OutputStream` where bytes will be written.
* @return
* Resource which manages the [[OutputStreamReporter]].
*/
def provision[F[_]: Concurrent: Async](
formatter: ResultFormatter,
output: Resource[F, OutputStream]
): Resource[F, OutputStreamReporter[F]] =
output.flatMap { os =>
Resource.make(acquireReporter(formatter, os))(_.stop())
}
private def acquireReporter[F[_]: Concurrent: Async](
formatter: ResultFormatter,
output: OutputStream
): F[OutputStreamReporter[F]] =
for
queue <- Queue.unbounded[F, Option[String]]
process <- startProcess[F](queue, output)
yield new OutputStreamReporter[F](
formatter = formatter,
state = new State[F](queue, process)
)
private def startProcess[F[_]: Concurrent: Async](
queue: Queue[F, Option[String]],
output: OutputStream
): F[Fiber[F, Throwable, Unit]] =
fs2.Stream
.fromQueueNoneTerminated(queue)
.through(text.utf8.encode)
.through(
fs2.io.writeOutputStream(Async[F].delay(output), closeAfterUse = false)
)
.compile
.drain
.start
private class State[F[_]](
val queue: Queue[F, Option[String]],
val process: Fiber[F, Throwable, Unit]
)
end OutputStreamReporter

View file

@ -0,0 +1,60 @@
package gs.test.v0.reporting
import cats.syntax.all.*
import gs.test.v0.api.GroupResult
import gs.test.v0.api.SuiteExecution
import gs.test.v0.api.TestExecution
import gs.test.v0.api.TestFailure
/** Implmentation of [[ResultFormatter]] that uses an unstructured text format.
*/
final class PlainResultFormatter extends ResultFormatter:
/** @inheritDocs
*/
override def prefix: String = ""
/** @inheritDocs
*/
override def suffix: String = ""
/** @inheritDocs
*/
override def formatGroupResult(groupResult: GroupResult): String =
def gr = groupResult
s"""
Group: '${gr.name.show}'
Stats: Seen=${gr.seen} Passed=${gr.passed} Failed=${gr.failed}
Duration: ${gr.millis}ms
Docs: ${gr.documentation.getOrElse("None")}
""".stripMargin
/** @inheritDocs
*/
override def formatTestExecution(testExecution: TestExecution): String =
def te = testExecution
s"""
Test: ${te.permanentId.show} (id=${te.id.show}) (span=${te.spanId})
Result: *${te.textResult}* in ${te.millis}ms
Tags: ${te.tags.mkString(", ")}
Docs: ${te.documentation.getOrElse("None")}${makeFailure(te.result)}
""".stripMargin
/** @inheritDocs
*/
override def formatSuiteExecution(suiteExecution: SuiteExecution): String =
def se = suiteExecution
s"""
Suite: '${se.testSuite.permanentId.show}' (id=${se.id.str}) (trace=${se.traceId})
Name: ${se.testSuite.name}
Stats: Seen=${se.seen} Passed=${se.passed} Failed=${se.failed}
Duration: ${se.millis}ms
""".stripMargin
private def makeFailure(result: Either[TestFailure, Any]): String =
result match
case Right(_) => ""
case Left(f) =>
s"""\n------
${f.message}
""".stripMargin

View file

@ -0,0 +1,63 @@
package gs.test.v0.reporting
import cats.Applicative
import gs.test.v0.api.GroupResult
import gs.test.v0.api.SuiteExecution
import gs.test.v0.api.TestExecution
/** Interface for reporters - implementations that report on test results.
*
* Example implementations include writing to standard output or writing to
* some JSON-formatted file.
*
* ## Order of Operations
*
* - `beginReporting()`
* - `reportGroup` for each group executed
* - `reportSuite`
* - `endReporting()`
*/
trait Reporter[F[_]]:
/** Hook for the beginning of the reporting lifecycle. This allows
* implementations to perform "setup" actions, such as opening a JSON object
* or writing a header.
*/
def startReport(): F[Unit]
/** Report the results of a single group.
*
* @param groupResult
* The [[GroupResult]] that describes the group level summary.
* @param testExecutions
* The list of [[TestExecution]] describing the result of each test.
* @return
* Side-effect that describes the reporting operation.
*/
def reportGroup(
groupResult: GroupResult,
testExecutions: List[TestExecution]
): F[Unit]
/** Report the results of an entire suite.
*
* @param suiteExecution
* The [[SuiteExecution]] that describes results.
* @return
* Side-effect that describes the reporting operation.
*/
def reportSuite(suiteExecution: SuiteExecution): F[Unit]
/** Hook for the end of the reporting lifecycle. This allows implementations
* to perform "finish" actions, such as closing a JSON object or writing a
* footer.
*/
def endReport(): F[Unit]
object Reporter:
/** @return
* New instance of the no-op Reporter implementation.
*/
def noop[F[_]: Applicative]: Reporter[F] = new NoopReporter[F]
end Reporter

View file

@ -0,0 +1,49 @@
package gs.test.v0.reporting
import gs.test.v0.api.GroupResult
import gs.test.v0.api.SuiteExecution
import gs.test.v0.api.TestExecution
/** Interface for formatters - implementations that transform test results into
* string representations.
*
* Example implementations include producing plain text or JSON
* representations.
*/
trait ResultFormatter:
/** @return
* The prefix for the format (if any).
*/
def prefix: String
/** @return
* The suffix for the format (if any).
*/
def suffix: String
/** Format a single [[GroupResult]] as a string.
*
* @param groupResult
* The result to format.
* @return
* The string rendition.
*/
def formatGroupResult(groupResult: GroupResult): String
/** Format a single [[TestExecution]] as a string.
*
* @param testExecution
* The result to format.
* @return
* The string rendition.
*/
def formatTestExecution(testExecution: TestExecution): String
/** Format a single [[SuiteExecution]] as a string.
*
* @param suiteExecution
* The result to format.
* @return
* The string rendition.
*/
def formatSuiteExecution(suiteExecution: SuiteExecution): String

View file

@ -1,17 +0,0 @@
package gs.test.v0.runtime
import gs.uuid.v0.UUID
import java.time.Instant
import scala.concurrent.duration.FiniteDuration
case class SuiteExecution(
id: UUID,
name: String,
documentation: Option[String],
duration: FiniteDuration,
countSeen: Long,
countSucceeded: Long,
countFailed: Long,
countIgnored: Long,
executedAt: Instant
)

View file

@ -1,5 +1,8 @@
package gs.test.v0.runtime.engine package gs.test.v0.runtime.engine
import gs.uuid.v0.UUID
import java.time.Clock
/** Used to control the behavior of some [[TestEngine]] /** Used to control the behavior of some [[TestEngine]]
* *
* @param groupConcurrency * @param groupConcurrency
@ -8,8 +11,17 @@ package gs.test.v0.runtime.engine
* @param testConcurrency * @param testConcurrency
* [[ConcurrencySetting]] for tests; the number of tests allowed to execute * [[ConcurrencySetting]] for tests; the number of tests allowed to execute
* at the same time within some group. * at the same time within some group.
* @param clock
* The `Clock` instance used to inform all date/time operations.
* @param suiteIdGenerator
* UUID provider that is used at the suite level.
* @param testIdGenerator
* UUID provider that is used at the test level.
*/ */
case class EngineConfiguration( case class EngineConfiguration(
groupConcurrency: ConcurrencySetting, groupConcurrency: ConcurrencySetting,
testConcurrency: ConcurrencySetting testConcurrency: ConcurrencySetting,
clock: Clock,
suiteIdGenerator: UUID.Generator,
testIdGenerator: UUID.Generator
) )

View file

@ -0,0 +1,27 @@
package gs.test.v0.runtime.engine
object EngineConstants:
object Tracing:
val RootSpan: String = "suite"
val FullGroup: String = "full-group"
val BeforeGroup: String = "before-group"
val AfterGroup: String = "after-group"
val FullTest: String = "full-test"
val BeforeTest: String = "before-test"
val AfterTest: String = "after-test"
val InGroup: String = "in-group"
val TestSpan: String = "test"
end Tracing
object MetaData:
val TestGroupName: String = "test_group_name"
val TestExecutionId: String = "test_execution_id"
val TestName: String = "test_name"
end MetaData
end EngineConstants

View file

@ -1,7 +0,0 @@
package gs.test.v0.runtime.engine
import gs.test.v0.runtime.SuiteExecution
final class EngineResult(
val suiteExecution: SuiteExecution
)

View file

@ -3,32 +3,98 @@ package gs.test.v0.runtime.engine
import cats.effect.Async import cats.effect.Async
import cats.effect.Ref import cats.effect.Ref
import cats.syntax.all.* import cats.syntax.all.*
import gs.test.v0.api.TestExecution
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import scala.concurrent.duration.FiniteDuration import scala.concurrent.duration.FiniteDuration
final class EngineStats[F[_]: Async]( /** Statistics for executed tests. Used by the [[TestEngine]].
val overallDuration: Ref[F, FiniteDuration], *
val countSeen: Ref[F, Long], * @param overallDuration
val countSucceeded: Ref[F, Long], * Duration of all recorded tests.
val countFailed: Ref[F, Long], * @param countSeen
val countIgnored: Ref[F, Long] * Number of tests encountered.
) * @param countPassed
* Number of tests that passed.
* @param countFailed
* Number of tests that failed.
*/
final class EngineStats[F[_]: Async] private (
overallDuration: Ref[F, FiniteDuration],
countSeen: Ref[F, Long],
countPassed: Ref[F, Long],
countFailed: Ref[F, Long]
):
/** @return
* The accumulated duration of test executions.
*/
def duration: F[FiniteDuration] = overallDuration.get
/** @return
* Number of tests encountered.
*/
def seen: F[Long] = countSeen.get
/** @return
* Number of tests that passed.
*/
def passed: F[Long] = countPassed.get
/** @return
* Number of tests that failed.
*/
def failed: F[Long] = countFailed.get
/** Update the stats based on the results of an entire group.
*
* @param duration
* The length of time it took to execute the group.
* @param testExecutions
* The list of all [[TestExecution]] produced by the group.
* @return
* Side-effect which updates statistic values.
*/
def updateForGroup(
duration: FiniteDuration,
testExecutions: List[TestExecution]
): F[Unit] =
for
_ <- overallDuration.update(base => base + duration)
_ <- testExecutions.map(updateForTest).sequence
yield ()
/** Update the stats based on the results of a single test.
*
* @param testExecution
* The [[TestExecution]] representing the test.
* @return
* Side-effect which updates statistic values.
*/
def updateForTest(testExecution: TestExecution): F[Unit] =
for
_ <- countSeen.update(_ + 1L)
_ <- testExecution.result match
case Left(_) => countFailed.update(_ + 1L)
case Right(_) => countPassed.update(_ + 1L)
yield ()
object EngineStats: object EngineStats:
/** Initialize a new [[EngineStats]] instance with all values set to 0.
*
* @return
* The new [[EngineStats]] instance.
*/
def initialize[F[_]: Async]: F[EngineStats[F]] = def initialize[F[_]: Async]: F[EngineStats[F]] =
for for
duration <- Ref.of(FiniteDuration(0L, TimeUnit.NANOSECONDS)) duration <- Ref.of(FiniteDuration(0L, TimeUnit.NANOSECONDS))
seen <- Ref.of(0L) seen <- Ref.of(0L)
succeeded <- Ref.of(0L) passed <- Ref.of(0L)
failed <- Ref.of(0L) failed <- Ref.of(0L)
ignored <- Ref.of(0L)
yield new EngineStats[F]( yield new EngineStats[F](
overallDuration = duration, overallDuration = duration,
countSeen = seen, countSeen = seen,
countSucceeded = succeeded, countPassed = passed,
countFailed = failed, countFailed = failed
countIgnored = ignored
) )
end EngineStats end EngineStats

View file

@ -1,12 +0,0 @@
package gs.test.v0.runtime.engine
import gs.test.v0.api.TestGroupDefinition
import gs.test.v0.runtime.TestExecution
import scala.concurrent.duration.FiniteDuration
final class GroupResult(
val name: TestGroupDefinition.Name,
val documentation: Option[String],
val duration: FiniteDuration,
val testExecutions: List[TestExecution]
)

View file

@ -1,113 +1,179 @@
package gs.test.v0.runtime.engine package gs.test.v0.runtime.engine
import cats.effect.Async import cats.effect.Async
import cats.effect.Resource
import cats.syntax.all.* import cats.syntax.all.*
import gs.test.v0.api.GroupResult
import gs.test.v0.api.SuiteExecution
import gs.test.v0.api.TestDefinition import gs.test.v0.api.TestDefinition
import gs.test.v0.api.TestExecution
import gs.test.v0.api.TestFailure import gs.test.v0.api.TestFailure
import gs.test.v0.api.TestGroupDefinition import gs.test.v0.api.TestGroupDefinition
import gs.test.v0.api.TestSuite import gs.test.v0.api.TestSuite
import gs.test.v0.runtime.SuiteExecution import gs.test.v0.reporting.Reporter
import gs.test.v0.runtime.TestExecution
import gs.timing.v0.Timing import gs.timing.v0.Timing
import gs.uuid.v0.UUID
import java.time.Clock
import java.time.Instant import java.time.Instant
import natchez.EntryPoint import natchez.EntryPoint
import natchez.Span import natchez.Span
/** This class is responsible for executing suites of tests.
*
* ## How Execution Works
*
* Test execution starts at the group level, via a stream of
* [[TestGroupDefinition]]. Each group of tests is executed concurrently based
* on the [[EngineConfiguration]].
*
* ### Executing a Single Group
*
* Each [[TestGroupDefinition]] is executed by executing, in order:
*
* - The `beforeGroup` effect.
* - Each test (configurable concurrency).
* - The `afterGroup` effect.
*
* The before/after effects are described at the group level.
*
* ### Executing a Single Test
*
* Each [[TestDefinition]] is executed by executing, in order:
*
* - The `beforeEachTest` effect.
* - The test code.
* - The `afterEachTest` effect.
*
* The before/after effects are described at the group level.
*
* ## OpenTelemetry Support
*
* Each [[SuiteExecution]] produces a single trace that encompasses all tests.
* Spans are used to designate different related portions of work.
*/
final class TestEngine[F[_]: Async]( final class TestEngine[F[_]: Async](
val configuration: EngineConfiguration, val configuration: EngineConfiguration,
timing: Timing[F], reporter: Reporter[F],
suiteExecutionIdGenerator: UUID.Generator, entryPoint: EntryPoint[F],
testExecutionIdGenerator: UUID.Generator, timing: Timing[F]
clock: Clock,
val entryPoint: EntryPoint[F]
): ):
private def clock = configuration.clock
private def testIdGen = configuration.testIdGenerator
private def suiteIdGen = configuration.suiteIdGenerator
def runSuite( def runSuite(
suite: TestSuite, suite: TestSuite,
tests: fs2.Stream[F, TestGroupDefinition[F]] tests: fs2.Stream[F, TestGroupDefinition[F]]
): F[SuiteExecution] = ): F[SuiteExecution] =
entryPoint.root(EngineConstants.Tracing.RootSpan).use { rootSpan =>
for for
executedAt <- Async[F].delay(Instant.now(clock)) executedAt <- Async[F].delay(Instant.now(clock))
stats <- EngineStats.initialize[F] stats <- EngineStats.initialize[F]
// Start reporting
_ <- reporter.startReport()
// Run all tests, group by group.
_ <- tests _ <- tests
.mapAsync(configuration.groupConcurrency.toInt())(runGroup) .mapAsync(configuration.groupConcurrency.toInt())(
.evalTap(updateGroupStats) runGroup(rootSpan, _)
.evalTap(reportGroup) )
.flatMap(groupResult => fs2.Stream.emits(groupResult.testExecutions)) .evalTap(
.evalTap(updateTestStats) (
.evalMap(reportTestExecution) groupResult,
testExecutions
) =>
for
// Update the overall statistics based on this group.
_ <- stats.updateForGroup(
duration = groupResult.duration,
testExecutions = testExecutions
)
// Report group level results for this group.
_ <- reporter.reportGroup(
groupResult = groupResult,
testExecutions = testExecutions
)
yield ()
)
.compile .compile
.drain .drain
overallDuration <- stats.overallDuration.get
countSeen <- stats.countSeen.get
countSucceeded <- stats.countSucceeded.get
countFailed <- stats.countFailed.get
countIgnored <- stats.countIgnored.get
yield SuiteExecution(
id = suiteExecutionIdGenerator.next(),
name = suite.name,
documentation = suite.documentation,
duration = overallDuration,
countSeen = countSeen,
countSucceeded = countSucceeded,
countFailed = countFailed,
countIgnored = countIgnored,
executedAt = executedAt
)
private def updateGroupStats(groupResult: GroupResult): F[Unit] = ??? // Calculate the final summary of execution at the suite level.
suiteExecution <- makeSuiteExecution(rootSpan, suite, stats, executedAt)
private def updateTestStats(testExecution: TestExecution): F[Unit] = ??? // Report suite level results.
_ <- reporter.reportSuite(suiteExecution)
private def reportGroup(groupResult: GroupResult): F[Unit] = ??? // Finish reporting.
_ <- reporter.endReport()
private def reportTestExecution(testExecution: TestExecution): F[Unit] = ??? yield suiteExecution
private def runSpan[A](
name: String,
root: Span[F],
f: F[A]
): F[A] =
root.span(name).use(_ => f)
def runGroup(
group: TestGroupDefinition[F]
): F[GroupResult] =
entryPoint.root("test-group").use { rootSpan =>
for
_ <- rootSpan.put("test_group_name" -> group.name.show)
_ <- runSpan(
"before-group",
rootSpan,
group.beforeGroup.getOrElse(Async[F].unit)
)
stream <- executeGroupTests(group, rootSpan)
_ <- runSpan(
"after-group",
rootSpan,
group.afterGroup.getOrElse(Async[F].unit)
)
yield stream
} }
private def executeGroupTests( def runGroup(
group: TestGroupDefinition[F], suiteSpan: Span[F],
rootSpan: Span[F] group: TestGroupDefinition[F]
): F[GroupResult] = ): F[(GroupResult, List[TestExecution])] =
rootSpan.span("group").use { groupSpan => suiteSpan.span(EngineConstants.Tracing.FullGroup).use { fullGroupSpan =>
for for
traceId <- rootSpan.traceId.map(parseTraceId) groupStats <- EngineStats.initialize[F]
// Augment the span with all group-level metadata.
_ <- fullGroupSpan
.put(EngineConstants.MetaData.TestGroupName -> group.name.show)
// Start the timer for the entire group.
timer <- timing.start() timer <- timing.start()
executions <- streamGroupTests(group, groupSpan).compile.toList
// Run the before-group logic (in its own span).
_ <- runSpan(
EngineConstants.Tracing.BeforeGroup,
fullGroupSpan,
group.beforeGroup.getOrElse(Async[F].unit)
)
// Execute all tests within this group.
testExecutions <- executeGroupTests(group, fullGroupSpan)
// Run the after-group logic (in its own span).
_ <- runSpan(
EngineConstants.Tracing.AfterGroup,
fullGroupSpan,
group.afterGroup.getOrElse(Async[F].unit)
)
// Calculate the overall elapsed time for this group.
elapsed <- timer.checkpoint() elapsed <- timer.checkpoint()
// Calculate group-level statistics.
_ <- groupStats.updateForGroup(
duration = elapsed.duration,
testExecutions = testExecutions
)
// Extract the group statistic values for inclusion in the result..
seen <- groupStats.seen
passed <- groupStats.passed
failed <- groupStats.failed
yield new GroupResult( yield new GroupResult(
name = group.name, name = group.name,
documentation = group.documentation, documentation = group.documentation,
duration = elapsed.duration, duration = elapsed.duration,
testExecutions = executions seen = seen,
) passed = passed,
failed = failed
) -> testExecutions
}
private def executeGroupTests(
group: TestGroupDefinition[F],
fullGroupSpan: Span[F]
): F[List[TestExecution]] =
fullGroupSpan.span(EngineConstants.Tracing.InGroup).use { groupSpan =>
for
// If, for some reason, the generated span has no Trace ID, this will
// throw an exception.
executions <- streamGroupTests(group, groupSpan).compile.toList
yield executions
} }
private def streamGroupTests( private def streamGroupTests(
@ -117,14 +183,37 @@ final class TestEngine[F[_]: Async](
fs2.Stream fs2.Stream
.emits(group.tests) .emits(group.tests)
.mapAsync(configuration.testConcurrency.toInt()) { test => .mapAsync(configuration.testConcurrency.toInt()) { test =>
groupSpan.span(EngineConstants.Tracing.FullTest).use { fullSpan =>
for for
// Generate a unique TestExecutionId for this execution.
testExecutionId <- Async[F].delay( testExecutionId <- Async[F].delay(
TestExecution.Id(testExecutionIdGenerator.next()) TestExecution.Id(testIdGen.next())
) )
testSpanId <- fullSpan.spanId.map(parseSpanId)
// Start the timer for the test, including the before/after
// components.
timer <- timing.start() timer <- timing.start()
_ <- group.beforeEachTest.getOrElse(Async[F].unit)
// Run the before-test logic (in its own span).
_ <- runSpan(
EngineConstants.Tracing.BeforeTest,
groupSpan,
group.beforeEachTest.getOrElse(Async[F].unit)
)
// Run the test (in its own span).
result <- runSingleTest(testExecutionId, test, groupSpan) result <- runSingleTest(testExecutionId, test, groupSpan)
_ <- group.afterEachTest.getOrElse(Async[F].unit)
// Run the after-test logic (in its own span).
_ <- runSpan(
EngineConstants.Tracing.AfterTest,
groupSpan,
group.afterEachTest.getOrElse(Async[F].unit)
)
// Calculate the overall elapsed time for this single test.
elapsed <- timer.checkpoint() elapsed <- timer.checkpoint()
yield TestExecution( yield TestExecution(
id = testExecutionId, id = testExecutionId,
@ -133,24 +222,109 @@ final class TestEngine[F[_]: Async](
tags = test.tags, tags = test.tags,
markers = test.markers, markers = test.markers,
result = result, result = result,
traceId = ???, spanId = testSpanId,
sourcePosition = test.sourcePosition, sourcePosition = test.sourcePosition,
duration = elapsed.duration duration = elapsed.duration
) )
} }
}
private def runSingleTest( private def runSingleTest(
testExecutionId: TestExecution.Id, testExecutionId: TestExecution.Id,
test: TestDefinition[F], test: TestDefinition[F],
groupSpan: Span[F] groupSpan: Span[F]
): F[Either[TestFailure, Any]] = ): F[Either[TestFailure, Any]] =
groupSpan.span("test").use { span => groupSpan.span(EngineConstants.Tracing.TestSpan).use { span =>
for for
// TODO: Constants _ <- span
_ <- span.put("test_execution_id" -> testExecutionId.show) .put(EngineConstants.MetaData.TestExecutionId -> testExecutionId.show)
_ <- span.put("test_name" -> test.name.show) _ <- span.put(EngineConstants.MetaData.TestName -> test.name.show)
result <- test.unitOfWork.doWork(span) result <- test.unitOfWork.doWork(span)
yield result yield result
} }
private def parseTraceId(candidate: Option[String]): UUID = ??? private def parseTraceId(candidate: Option[String]): String =
candidate match
case Some(traceId) => traceId
case None =>
throw new IllegalArgumentException("Created a span without a Trace ID!")
private def parseSpanId(candidate: Option[String]): String =
candidate match
case Some(spanId) => spanId
case None =>
throw new IllegalArgumentException("Created a span without a Span ID!")
private def makeSuiteExecution(
rootSpan: Span[F],
suite: TestSuite,
stats: EngineStats[F],
executedAt: Instant
): F[SuiteExecution] =
for
traceId <- rootSpan.traceId.map(parseTraceId)
overallDuration <- stats.duration
seen <- stats.seen
passed <- stats.passed
failed <- stats.failed
yield SuiteExecution(
id = suiteIdGen.next(),
testSuite = suite,
traceId = traceId,
duration = overallDuration,
seen = seen,
passed = passed,
failed = failed,
executedAt = executedAt
)
/** Run some effect as a child span for some root span.
*
* @param name
* The name of the span.
* @param root
* The root span.
* @param f
* The effect to execute in a child span.
* @return
* The contextualized effect.
*/
private def runSpan[A](
name: String,
root: Span[F],
f: F[A]
): F[A] =
root.span(name).use(_ => f)
object TestEngine:
/** Provision a new [[TestEngine]].
*
* @param configuration
* The [[EngineConfiguration]] used for this instance.
* @param reporter
* Resource which manages the [[Reporter]].
* @param entryPoint
* Resource which manages the telemetry entry point.
* @param timing
* Timing controller.
* @return
* Resource which manages the [[TestEngine]].
*/
def provision[F[_]: Async](
configuration: EngineConfiguration,
reporter: Resource[F, Reporter[F]],
entryPoint: Resource[F, EntryPoint[F]],
timing: Timing[F]
): Resource[F, TestEngine[F]] =
for
r <- reporter
ep <- entryPoint
yield new TestEngine(
configuration = configuration,
reporter = r,
entryPoint = ep,
timing = timing
)
end TestEngine

View file

@ -1 +1 @@
sbt.version=1.10.2 sbt.version=1.11.2

View file

@ -28,6 +28,6 @@ externalResolvers := Seq(
"Garrity Software Releases" at "https://maven.garrity.co/gs" "Garrity Software Releases" at "https://maven.garrity.co/gs"
) )
addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.1.0") addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.3.1")
addSbtPlugin("gs" % "sbt-garrity-software" % "0.4.0") addSbtPlugin("gs" % "sbt-garrity-software" % "0.6.0")
addSbtPlugin("gs" % "sbt-gs-semver" % "0.3.0") addSbtPlugin("gs" % "sbt-gs-semver" % "0.3.0")