Now running real tests

This commit is contained in:
Pat Garrity 2025-09-13 15:54:25 -05:00
parent 3137fe4005
commit 5294ea669a
Signed by: pfm
GPG key ID: 5CA5D21BAB7F3A76
7 changed files with 141 additions and 85 deletions

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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]: