From 5294ea669a91177c6c6599d72a159cbcb3258603 Mon Sep 17 00:00:00 2001 From: Pat Garrity Date: Sat, 13 Sep 2025 15:54:25 -0500 Subject: [PATCH] Now running real tests --- .../main/scala/gs/test/v0/api/Assertion.scala | 62 ---------------- .../src/main/scala/gs/test/v0/api/Check.scala | 72 +++++++++++++++---- .../main/scala/gs/test/v0/api/syntax.scala | 2 +- .../v0/api/GroupImplementationTests.scala | 8 --- .../test/v0/reporting/InMemoryReporter.scala | 27 +++++++ .../v0/runtime/engine/TestEngineTests.scala | 54 +++++++++++++- .../test/scala/support/TestEntryPoint.scala | 1 + 7 files changed, 141 insertions(+), 85 deletions(-) delete mode 100644 modules/api/src/main/scala/gs/test/v0/api/Assertion.scala diff --git a/modules/api/src/main/scala/gs/test/v0/api/Assertion.scala b/modules/api/src/main/scala/gs/test/v0/api/Assertion.scala deleted file mode 100644 index 31dbb90..0000000 --- a/modules/api/src/main/scala/gs/test/v0/api/Assertion.scala +++ /dev/null @@ -1,62 +0,0 @@ -package gs.test.v0.api - -import scala.reflect.* - -sealed abstract class Assertion(val name: String) - -object Assertion: - - private def success(): Either[TestFailure, Unit] = Right(()) - - // TODO: Use a smart rendering solution, consider diffs. - // For now, this is fine -- add a diff library later as data. - def renderInput[A](value: A): String = value.toString() - - case object IsEqualTo extends Assertion("isEqualTo"): - - def evaluate[A: ClassTag]( - candidate: A, - expected: A - )( - using - CanEqual[A, A] - )( - using - pos: SourcePosition - ): Either[TestFailure, Unit] = - if candidate == expected then success() - else - val runtimeType = classTag[A].runtimeClass.getName() - Left( - TestFailure.AssertionFailed( - assertionName = name, - inputs = Map( - "candidate" -> runtimeType, - "expected" -> runtimeType - ), - message = - s"'${renderInput(candidate)}' was not equal to '${renderInput(candidate)}'", - pos = pos - ) - ) - - case object IsTrue extends Assertion("isTrue"): - - def evaluate( - candidate: Boolean - )( - using - pos: SourcePosition - ): Either[TestFailure, Unit] = - if candidate then success() - else - Left( - TestFailure.AssertionFailed( - assertionName = name, - inputs = Map("candidate" -> "Boolean"), - message = s"Expected '$candidate' to be 'true'.", - pos = pos - ) - ) - -end Assertion diff --git a/modules/api/src/main/scala/gs/test/v0/api/Check.scala b/modules/api/src/main/scala/gs/test/v0/api/Check.scala index 93efe51..d586a3e 100644 --- a/modules/api/src/main/scala/gs/test/v0/api/Check.scala +++ b/modules/api/src/main/scala/gs/test/v0/api/Check.scala @@ -1,9 +1,11 @@ package gs.test.v0.api +import cats.data.EitherT import cats.effect.Sync +import scala.reflect.* import scala.reflect.ClassTag -/** Opaque type used to check candidate values against expected values. +/** Opaque type used to check obtained values against expected values. */ opaque type Check[A] = A @@ -11,18 +13,38 @@ object Check: /** Instantiate a new Check. * - * @param candidate + * @param obtained * The value to check. * @return * The new [[Check]] instance. */ - def apply[A](candidate: A): Check[A] = candidate + def apply[A](obtained: A): Check[A] = obtained - extension [A](candidate: Check[A]) + private def testPassed(): TestResult = Right(()) + + private def render[A](value: A): String = value.toString() + + private def assertionFailed( + assertionName: String, + inputs: Map[String, String], + message: String + )( + using + pos: SourcePosition + ): TestResult = Left( + TestFailure.AssertionFailed( + assertionName = assertionName, + inputs = inputs, + message = message, + pos = pos + ) + ) + + extension [A](obtained: Check[A]) /** @return * The unwrapped value of this [[Check]]. */ - def unwrap(): A = candidate + def unwrap(): A = obtained /** ## Usage * @@ -34,7 +56,7 @@ object Check: * @param expected * The expected value. * @return - * Successful test result if the candidate value is equal to the expected + * Successful test result if the obtained value is equal to the expected * value, an error describing the test failure otherwise. */ def isEqualTo( @@ -45,7 +67,18 @@ object Check: ClassTag[A], SourcePosition ): TestResult = - Assertion.IsEqualTo.evaluate(candidate, expected) + if obtained == expected then testPassed() + else + val runtimeType = classTag[A].runtimeClass.getName() + assertionFailed( + assertionName = "isEqualTo", + inputs = Map( + "obtained" -> runtimeType, + "expected" -> runtimeType + ), + message = + s"'${render(obtained)}' was not equal to '${render(expected)}'" + ) /** ## Usage * @@ -60,8 +93,8 @@ object Check: * The expected value. * @return * Effect that when evaluated will produce: successful test result if the - * candidate value is equal to the expected value, an error describing - * the test failure otherwise. + * obtained value is equal to the expected value, an error describing the + * test failure otherwise. */ def isEqualToF[F[_]: Sync]( expected: A @@ -73,7 +106,7 @@ object Check: ): F[TestResult] = Sync[F].delay(isEqualTo(expected)) - extension (check: Check[Boolean]) + extension (obtained: Check[Boolean]) /** ## Usage * @@ -83,7 +116,7 @@ object Check: * }}} * * @return - * Successful test result if the candidate value is `true`, an error + * Successful test result if the obtained value is `true`, an error * describing the test failure otherwise. */ def isTrue( @@ -91,7 +124,13 @@ object Check: using SourcePosition ): TestResult = - Assertion.IsTrue.evaluate(check) + if obtained then testPassed() + else + assertionFailed( + assertionName = "isTrue", + inputs = Map("obtained" -> "Boolean"), + message = s"Expected '$obtained' to be 'true'." + ) /** ## Usage * @@ -104,7 +143,7 @@ object Check: * * @return * Effect that when evaluated will produce: successful test result if the - * candidate value is `true`, an error describing the test failure + * obtained value is `true`, an error describing the test failure * otherwise. */ def isTrueF[F[_]: Sync]( @@ -114,4 +153,11 @@ object Check: ): F[TestResult] = Sync[F].delay(isTrue()) + def isTrueT[F[_]: Sync]( + )( + using + SourcePosition + ): EitherT[F, TestFailure, Any] = + EitherT(Sync[F].delay(isTrue())) + end Check diff --git a/modules/api/src/main/scala/gs/test/v0/api/syntax.scala b/modules/api/src/main/scala/gs/test/v0/api/syntax.scala index b20128f..0c8a41a 100644 --- a/modules/api/src/main/scala/gs/test/v0/api/syntax.scala +++ b/modules/api/src/main/scala/gs/test/v0/api/syntax.scala @@ -180,4 +180,4 @@ def checkAllF[F[_]: Sync]( } } -def check[A](candidate: A): Check[A] = Check(candidate) +def check[A](obtained: A): Check[A] = Check(obtained) diff --git a/modules/api/src/test/scala/gs/test/v0/api/GroupImplementationTests.scala b/modules/api/src/test/scala/gs/test/v0/api/GroupImplementationTests.scala index 2b962c3..27d0483 100644 --- a/modules/api/src/test/scala/gs/test/v0/api/GroupImplementationTests.scala +++ b/modules/api/src/test/scala/gs/test/v0/api/GroupImplementationTests.scala @@ -132,12 +132,4 @@ object GroupImplementationTests: end G3 - class G4[F[_]: Async] extends TestGroup[F]: - override def name: String = "G4" - - // TODO: Make test entrypoint and test Trace[F] - test(Ids.T4, "Effectful test").effectful { - ??? - } - end GroupImplementationTests diff --git a/modules/reporting/src/main/scala/gs/test/v0/reporting/InMemoryReporter.scala b/modules/reporting/src/main/scala/gs/test/v0/reporting/InMemoryReporter.scala index 08bde16..5883425 100644 --- a/modules/reporting/src/main/scala/gs/test/v0/reporting/InMemoryReporter.scala +++ b/modules/reporting/src/main/scala/gs/test/v0/reporting/InMemoryReporter.scala @@ -9,6 +9,19 @@ import gs.test.v0.api.GroupResult import gs.test.v0.api.SuiteExecution import gs.test.v0.api.TestExecution +/** Reporter that collects all results into memory. + * + * ### Lifecycle + * + * This reporter is intended to be used to get results _exactly once_, after + * the engine has completed its work. The function `terminateAndGetResults` + * will remove all results from the internal collection. + * + * @param suiteExecution + * Collector for the [[SuiteExecution]]. + * @param groupResults + * Collector for grouped test results. + */ final class InMemoryReporter[F[_]: Async] private ( suiteExecution: Ref[F, Option[SuiteExecution]], groupResults: Queue[F, Option[(GroupResult, List[TestExecution])]] @@ -34,14 +47,28 @@ final class InMemoryReporter[F[_]: Async] private ( */ override def endReport(): F[Unit] = groupResults.offer(None) + /** @return + * The recorded [[SuiteExecution]]. This will be empty unless `reportSuite` + * was called. + */ def getSuiteExecution(): F[Option[SuiteExecution]] = suiteExecution.get + /** Immediatelly call `endReport` (to ensure terminal state) and collect all + * recorded results. This is a destructive call that will remove the results + * from this reporter. + * + * @return + * The list of extracted results. + */ def terminateAndGetResults(): F[List[(GroupResult, List[TestExecution])]] = endReport() *> fs2.Stream.fromQueueNoneTerminated(groupResults).compile.toList object InMemoryReporter: + /** @return + * Resource that provides a new [[InMemoryReporter]]. + */ def provision[F[_]: Async]: Resource[F, InMemoryReporter[F]] = Resource.make( for diff --git a/modules/runtime/src/test/scala/gs/test/v0/runtime/engine/TestEngineTests.scala b/modules/runtime/src/test/scala/gs/test/v0/runtime/engine/TestEngineTests.scala index aaf7bd1..5b22a64 100644 --- a/modules/runtime/src/test/scala/gs/test/v0/runtime/engine/TestEngineTests.scala +++ b/modules/runtime/src/test/scala/gs/test/v0/runtime/engine/TestEngineTests.scala @@ -2,9 +2,10 @@ package gs.test.v0.runtime.engine import cats.effect.IO import cats.effect.Resource -import gs.test.v0.api.TestGroupDefinition +import gs.test.v0.api.* import gs.test.v0.reporting.InMemoryReporter import gs.test.v0.runtime.engine.TestEngineTests.EngineObservation +import gs.test.v0.runtime.engine.TestEngineTests.G1 import gs.timing.v0.MonotonicProvider.ManualTickProvider import gs.timing.v0.Timing import gs.uuid.v0.UUID @@ -34,6 +35,50 @@ class TestEngineTests extends IOSuite: } } + iotest("should run an engine with a single passing test") { + newEngine().use { obs => + val g1 = new G1 + val group = g1.compile() + for + suiteExecution <- obs.engine.runSuite( + suite = Generators.testSuite(), + tests = fs2.Stream.apply(group) + ) + rootSpan <- obs.entryPoint.getSpan(EngineConstants.Tracing.RootSpan) + groupSpan <- obs.entryPoint.getSpan(EngineConstants.Tracing.FullGroup) + beforeGroupSpan <- obs.entryPoint.getSpan( + EngineConstants.Tracing.BeforeGroup + ) + afterGroupSpan <- obs.entryPoint.getSpan( + EngineConstants.Tracing.AfterGroup + ) + inGroupSpan <- obs.entryPoint.getSpan(EngineConstants.Tracing.InGroup) + fullTestSpan <- obs.entryPoint.getSpan(EngineConstants.Tracing.FullTest) + beforeTestSpan <- obs.entryPoint.getSpan( + EngineConstants.Tracing.BeforeTest + ) + afterTestSpan <- obs.entryPoint.getSpan( + EngineConstants.Tracing.AfterTest + ) + testSpan <- obs.entryPoint.getSpan(EngineConstants.Tracing.TestSpan) + results <- obs.reporter.terminateAndGetResults() + yield + assertEquals(rootSpan.isDefined, true) + assertEquals(groupSpan.isDefined, true) + assertEquals(beforeGroupSpan.isDefined, true) + assertEquals(afterGroupSpan.isDefined, true) + assertEquals(inGroupSpan.isDefined, true) + assertEquals(fullTestSpan.isDefined, true) + assertEquals(beforeTestSpan.isDefined, true) + assertEquals(afterTestSpan.isDefined, true) + assertEquals(testSpan.isDefined, true) + assertEquals(results.size, 1) + assertEquals(suiteExecution.seen, 1L) + assertEquals(suiteExecution.passed, 1L) + assertEquals(suiteExecution.failed, 0L) + } + } + private def emptyStream[A]: fs2.Stream[IO, A] = fs2.Stream.empty @@ -78,4 +123,11 @@ object TestEngineTests: engine: TestEngine[IO] ) + class G1 extends TestGroup[IO]: + override def name: String = "single-passing-test" + + test(pid"engine:g1", "show that true is true") { + check(true).isTrueT() + } + end TestEngineTests diff --git a/modules/test-support/src/test/scala/support/TestEntryPoint.scala b/modules/test-support/src/test/scala/support/TestEntryPoint.scala index f4f167b..432a03e 100644 --- a/modules/test-support/src/test/scala/support/TestEntryPoint.scala +++ b/modules/test-support/src/test/scala/support/TestEntryPoint.scala @@ -8,6 +8,7 @@ import natchez.Kernel import natchez.Span import natchez.Span.Options +// TODO: This doesn't account for multiple spans with the same name. final class TestEntryPoint private ( spans: MapRef[IO, String, Option[TestSpan]] ) extends EntryPoint[IO]: