Bootstrapping the gs-test framework.
This commit is contained in:
commit
d9192843da
16 changed files with 907 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
target/
|
||||||
|
project/target/
|
||||||
|
project/project/
|
||||||
|
modules/core/target/
|
||||||
|
.version
|
||||||
|
.scala-build/
|
17
.pre-commit-config.yaml
Normal file
17
.pre-commit-config.yaml
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
---
|
||||||
|
repos:
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v4.5.0
|
||||||
|
hooks:
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: trailing-whitespace
|
||||||
|
- id: fix-byte-order-marker
|
||||||
|
- id: mixed-line-ending
|
||||||
|
args: ['--fix=lf']
|
||||||
|
description: Enforces using only 'LF' line endings.
|
||||||
|
- id: trailing-whitespace
|
||||||
|
- id: check-yaml
|
||||||
|
- repo: https://git.garrity.co/garrity-software/gs-pre-commit-scala
|
||||||
|
rev: v1.0.1
|
||||||
|
hooks:
|
||||||
|
- id: scalafmt
|
72
.scalafmt.conf
Normal file
72
.scalafmt.conf
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
// See: https://github.com/scalameta/scalafmt/tags for the latest tags.
|
||||||
|
version = 3.8.1
|
||||||
|
runner.dialect = scala3
|
||||||
|
maxColumn = 80
|
||||||
|
|
||||||
|
rewrite {
|
||||||
|
rules = [RedundantBraces, RedundantParens, Imports, SortModifiers]
|
||||||
|
imports.expand = true
|
||||||
|
imports.sort = scalastyle
|
||||||
|
redundantBraces.ifElseExpressions = true
|
||||||
|
redundantBraces.stringInterpolation = true
|
||||||
|
}
|
||||||
|
|
||||||
|
indent {
|
||||||
|
main = 2
|
||||||
|
callSite = 2
|
||||||
|
defnSite = 2
|
||||||
|
extendSite = 4
|
||||||
|
withSiteRelativeToExtends = 2
|
||||||
|
commaSiteRelativeToExtends = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
align {
|
||||||
|
preset = more
|
||||||
|
openParenCallSite = false
|
||||||
|
openParenDefnSite = false
|
||||||
|
}
|
||||||
|
|
||||||
|
newlines {
|
||||||
|
implicitParamListModifierForce = [before,after]
|
||||||
|
topLevelStatementBlankLines = [
|
||||||
|
{
|
||||||
|
blanks = 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
afterCurlyLambdaParams = squash
|
||||||
|
}
|
||||||
|
|
||||||
|
danglingParentheses {
|
||||||
|
defnSite = true
|
||||||
|
callSite = true
|
||||||
|
ctrlSite = true
|
||||||
|
exclude = []
|
||||||
|
}
|
||||||
|
|
||||||
|
verticalMultiline {
|
||||||
|
atDefnSite = true
|
||||||
|
arityThreshold = 2
|
||||||
|
newlineAfterOpenParen = true
|
||||||
|
}
|
||||||
|
|
||||||
|
comments {
|
||||||
|
wrap = standalone
|
||||||
|
}
|
||||||
|
|
||||||
|
docstrings {
|
||||||
|
style = "SpaceAsterisk"
|
||||||
|
oneline = unfold
|
||||||
|
wrap = yes
|
||||||
|
forceBlankLineBefore = true
|
||||||
|
}
|
||||||
|
|
||||||
|
project {
|
||||||
|
excludePaths = [
|
||||||
|
"glob:**target/**",
|
||||||
|
"glob:**.metals/**",
|
||||||
|
"glob:**.bloop/**",
|
||||||
|
"glob:**.bsp/**",
|
||||||
|
"glob:**metals.sbt",
|
||||||
|
"glob:**.git/**"
|
||||||
|
]
|
||||||
|
}
|
9
LICENSE
Normal file
9
LICENSE
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright Patrick Garrity
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
31
README.md
Normal file
31
README.md
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
# gs-test
|
||||||
|
|
||||||
|
[GS Open Source](https://garrity.co/oss.html) |
|
||||||
|
[License (MIT)](./LICENSE)
|
||||||
|
|
||||||
|
Test framework for Scala 3. Based on
|
||||||
|
[Cats Effect](https://typelevel.org/cats-effect/) and
|
||||||
|
[FS2](https://fs2.io/#/).
|
||||||
|
|
||||||
|
- [Usage](#usage)
|
||||||
|
- [Dependency](#dependency)
|
||||||
|
- [Donate](#donate)
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Dependency
|
||||||
|
|
||||||
|
This artifact is available in the Garrity Software Maven repository.
|
||||||
|
|
||||||
|
```scala
|
||||||
|
externalResolvers +=
|
||||||
|
"Garrity Software Releases" at "https://maven.garrity.co/gs"
|
||||||
|
|
||||||
|
val GsLog: ModuleID =
|
||||||
|
"gs" %% "gs-test-core-v0" % "$VERSION"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Donate
|
||||||
|
|
||||||
|
Enjoy this project or want to help me achieve my [goals](https://garrity.co)?
|
||||||
|
Consider [Donating to Pat on Ko-fi](https://ko-fi.com/gspfm).
|
63
build.sbt
Normal file
63
build.sbt
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
val scala3: String = "3.4.2"
|
||||||
|
|
||||||
|
ThisBuild / scalaVersion := scala3
|
||||||
|
ThisBuild / versionScheme := Some("semver-spec")
|
||||||
|
ThisBuild / gsProjectName := "gs-test"
|
||||||
|
|
||||||
|
ThisBuild / externalResolvers := Seq(
|
||||||
|
"Garrity Software Mirror" at "https://maven.garrity.co/releases",
|
||||||
|
"Garrity Software Releases" at "https://maven.garrity.co/gs"
|
||||||
|
)
|
||||||
|
|
||||||
|
val noPublishSettings = Seq(
|
||||||
|
publish := {}
|
||||||
|
)
|
||||||
|
|
||||||
|
val sharedSettings = Seq(
|
||||||
|
scalaVersion := scala3,
|
||||||
|
version := semVerSelected.value,
|
||||||
|
coverageFailOnMinimum := true
|
||||||
|
/* coverageMinimumStmtTotal := 100, coverageMinimumBranchTotal := 100 */
|
||||||
|
)
|
||||||
|
|
||||||
|
val Deps = new {
|
||||||
|
val Cats = new {
|
||||||
|
val Core: ModuleID = "org.typelevel" %% "cats-core" % "2.12.0"
|
||||||
|
val Effect: ModuleID = "org.typelevel" %% "cats-effect" % "3.5.4"
|
||||||
|
}
|
||||||
|
|
||||||
|
val Fs2 = new {
|
||||||
|
val Core: ModuleID = "co.fs2" %% "fs2-core" % "3.10.2"
|
||||||
|
}
|
||||||
|
|
||||||
|
val Gs = new {
|
||||||
|
val Uuid: ModuleID = "gs" %% "gs-uuid-v0" % "0.3.0"
|
||||||
|
val Datagen: ModuleID = "gs" %% "gs-datagen-core-v0" % "0.2.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
val MUnit: ModuleID = "org.scalameta" %% "munit" % "1.0.1"
|
||||||
|
}
|
||||||
|
|
||||||
|
lazy val testSettings = Seq(
|
||||||
|
libraryDependencies ++= Seq(
|
||||||
|
Deps.MUnit % Test,
|
||||||
|
Deps.Gs.Datagen % Test
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
lazy val `gs-test` = project
|
||||||
|
.in(file("."))
|
||||||
|
.aggregate(core)
|
||||||
|
.settings(noPublishSettings)
|
||||||
|
.settings(name := s"${gsProjectName.value}-v${semVerMajor.value}")
|
||||||
|
|
||||||
|
lazy val core = project
|
||||||
|
.in(file("modules/core"))
|
||||||
|
.settings(sharedSettings)
|
||||||
|
.settings(testSettings)
|
||||||
|
.settings(name := s"${gsProjectName.value}-core-v${semVerMajor.value}")
|
||||||
|
.settings(libraryDependencies ++= Seq(
|
||||||
|
Deps.Cats.Core,
|
||||||
|
Deps.Cats.Effect,
|
||||||
|
Deps.Fs2.Core
|
||||||
|
))
|
15
modules/core/src/main/scala/gs/test/v0/GsTestError.scala
Normal file
15
modules/core/src/main/scala/gs/test/v0/GsTestError.scala
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
package gs.test.v0
|
||||||
|
|
||||||
|
sealed trait GsTestError
|
||||||
|
|
||||||
|
object GsTestError:
|
||||||
|
|
||||||
|
sealed trait TestDefinitionError extends GsTestError
|
||||||
|
|
||||||
|
object TestDefinitionError:
|
||||||
|
|
||||||
|
case class InvalidIterations(candidate: Int) extends TestDefinitionError
|
||||||
|
|
||||||
|
end TestDefinitionError
|
||||||
|
|
||||||
|
end GsTestError
|
38
modules/core/src/main/scala/gs/test/v0/PermanentId.scala
Normal file
38
modules/core/src/main/scala/gs/test/v0/PermanentId.scala
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
package gs.test.v0
|
||||||
|
|
||||||
|
import cats.Show
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opaque type representing some _permanent identifier_. These are
|
||||||
|
* user-assigned strings that are expected to _not change over time_ for some
|
||||||
|
* test. This allows tests to be deterministically tracked. The only constraint
|
||||||
|
* for a permanent identifier is that it must not be blank.
|
||||||
|
*
|
||||||
|
* ## Uniqueness
|
||||||
|
*
|
||||||
|
* Permanent identifiers are expected to be unique within the scope of a
|
||||||
|
* [[TestSuite]]. This means that two groups within the same suite _may not_
|
||||||
|
* contain tests that share a permanent identifier.
|
||||||
|
*/
|
||||||
|
opaque type PermanentId = String
|
||||||
|
|
||||||
|
object PermanentId:
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instantiate a new [[PermanentId]].
|
||||||
|
*
|
||||||
|
* @param candidate The candidate string.
|
||||||
|
* @return The new [[PermanentId]] instance.
|
||||||
|
* @throws IllegalArgumentException If the candidate string is blank.
|
||||||
|
*/
|
||||||
|
def apply(candidate: String): PermanentId =
|
||||||
|
if candidate.isBlank() then
|
||||||
|
throw new IllegalArgumentException("Permanent Identifiers must be non-blank.")
|
||||||
|
else
|
||||||
|
candidate
|
||||||
|
|
||||||
|
given CanEqual[PermanentId, PermanentId] = CanEqual.derived
|
||||||
|
|
||||||
|
given Show[PermanentId] = pid => pid
|
||||||
|
|
||||||
|
end PermanentId
|
124
modules/core/src/main/scala/gs/test/v0/TestDefinition.scala
Normal file
124
modules/core/src/main/scala/gs/test/v0/TestDefinition.scala
Normal file
|
@ -0,0 +1,124 @@
|
||||||
|
package gs.test.v0
|
||||||
|
|
||||||
|
import cats.data.EitherT
|
||||||
|
import cats.Show
|
||||||
|
import gs.test.v0.GsTestError.TestDefinitionError.InvalidIterations
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Each instance of this class indicates the _definition_ of some test.
|
||||||
|
*
|
||||||
|
* @param name The display name of the test. Not considered to be unique.
|
||||||
|
* @param permanentId The [[PermanentId]] for this test.
|
||||||
|
* @param tags The set of [[Test.Tag]] applicable to this test.
|
||||||
|
* @param iterations The number of iterations of this test to run.
|
||||||
|
* @param f The effect that the test evaluates.
|
||||||
|
*/
|
||||||
|
final class TestDefinition[F[_]](
|
||||||
|
val name: TestDefinition.Name,
|
||||||
|
val permanentId: PermanentId,
|
||||||
|
val documentation: Option[String],
|
||||||
|
val tags: List[TestDefinition.Tag],
|
||||||
|
val markers: List[TestDefinition.Marker],
|
||||||
|
val iterations: TestDefinition.Iterations,
|
||||||
|
val unitOfWork: EitherT[F, TestFailure, Unit]
|
||||||
|
)
|
||||||
|
|
||||||
|
object TestDefinition:
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opaque type representing names that may be assigned to [[Test]].
|
||||||
|
*/
|
||||||
|
opaque type Name = String
|
||||||
|
|
||||||
|
object Name:
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instantiate a new [[Test.Name]]. This name is not unique, has no
|
||||||
|
* constraints, and only exists for display purposes.
|
||||||
|
*
|
||||||
|
* @param name The candidate string.
|
||||||
|
* @return The new [[Test.Name]] instance.
|
||||||
|
*/
|
||||||
|
def apply(name: String): Name = name
|
||||||
|
|
||||||
|
given CanEqual[Name, Name] = CanEqual.derived
|
||||||
|
|
||||||
|
given Show[Name] = name => name
|
||||||
|
|
||||||
|
end Name
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opaque type representing tags that may be assigned to a [[Test]].
|
||||||
|
*/
|
||||||
|
opaque type Tag = String
|
||||||
|
|
||||||
|
object Tag:
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instantiate a new [[Test.Tag]].
|
||||||
|
*
|
||||||
|
* @param tag The candidate string.
|
||||||
|
* @return The new [[Test.Tag]] instance.
|
||||||
|
*/
|
||||||
|
def apply(tag: String): Tag = tag
|
||||||
|
|
||||||
|
given CanEqual[Tag, Tag] = CanEqual.derived
|
||||||
|
|
||||||
|
given Show[Tag] = tag => tag
|
||||||
|
|
||||||
|
end Tag
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enumeration for _Markers_, special tokens which "mark" a test to change
|
||||||
|
* execution functionality.
|
||||||
|
*
|
||||||
|
* The basic case for this enumeration is allowing tests to be ignored.
|
||||||
|
*
|
||||||
|
* @param name The formal serialized name of the marker.
|
||||||
|
*/
|
||||||
|
sealed abstract class Marker(val name: String)
|
||||||
|
|
||||||
|
object Marker:
|
||||||
|
|
||||||
|
given CanEqual[Marker, Marker] = CanEqual.derived
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If this [[Test.Marker]] is present on a test, the test will be ignored.
|
||||||
|
*/
|
||||||
|
case object Ignored extends Marker("ignored")
|
||||||
|
|
||||||
|
end Marker
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opaque type that represents the number of iterations a test should run.
|
||||||
|
* This value must be at least `1` (the default). To ignore a test, use the
|
||||||
|
* [[Test.Marker.Ignored]] marker.
|
||||||
|
*/
|
||||||
|
opaque type Iterations = Int
|
||||||
|
|
||||||
|
object Iterations:
|
||||||
|
|
||||||
|
def One: Iterations = 1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and instantiate a new [[Iterations]] instance.
|
||||||
|
*
|
||||||
|
* @param candidate The candidate value. Must be 1 or greater.
|
||||||
|
* @return The new [[Iterations]], or an error if an invalid input is given.
|
||||||
|
*/
|
||||||
|
def validate(candidate: Int): Either[GsTestError, Iterations] =
|
||||||
|
if candidate < 1 then
|
||||||
|
Left(InvalidIterations(candidate))
|
||||||
|
else
|
||||||
|
Right(candidate)
|
||||||
|
|
||||||
|
given CanEqual[Iterations, Iterations] = CanEqual.derived
|
||||||
|
|
||||||
|
given Show[Iterations] = iters => iters.toString()
|
||||||
|
|
||||||
|
extension (iters: Iterations)
|
||||||
|
def toInt(): Int = iters
|
||||||
|
|
||||||
|
end Iterations
|
||||||
|
|
||||||
|
end TestDefinition
|
42
modules/core/src/main/scala/gs/test/v0/TestFailure.scala
Normal file
42
modules/core/src/main/scala/gs/test/v0/TestFailure.scala
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
package gs.test.v0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base trait for all failures recognized by gs-test.
|
||||||
|
*/
|
||||||
|
sealed trait TestFailure
|
||||||
|
|
||||||
|
object TestFailure:
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returned when assertions in this library fail. Assertions understand how to
|
||||||
|
* populate these values.
|
||||||
|
*
|
||||||
|
* @param assertionName The name of the assertion.
|
||||||
|
* @param inputs The names and calculated types of each input to the assertion.
|
||||||
|
* @param message The message produced by the assertion.
|
||||||
|
*/
|
||||||
|
case class AssertionFailed(
|
||||||
|
assertionName: String,
|
||||||
|
inputs: Map[String, String],
|
||||||
|
message: String
|
||||||
|
) extends TestFailure
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return when a test explicitly calls `fail("...")` or some variant thereof.
|
||||||
|
*
|
||||||
|
* @param message The failure message provided by the test author.
|
||||||
|
*/
|
||||||
|
case class TestRequestedFailure(
|
||||||
|
message: String
|
||||||
|
) extends TestFailure
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used when the test fails due to an exception.
|
||||||
|
*
|
||||||
|
* @param cause The underlying cause of failure.
|
||||||
|
*/
|
||||||
|
case class ExceptionThrown(
|
||||||
|
cause: Throwable
|
||||||
|
) extends TestFailure
|
||||||
|
|
||||||
|
end TestFailure
|
341
modules/core/src/main/scala/gs/test/v0/TestGroup.scala
Normal file
341
modules/core/src/main/scala/gs/test/v0/TestGroup.scala
Normal file
|
@ -0,0 +1,341 @@
|
||||||
|
package gs.test.v0
|
||||||
|
|
||||||
|
import cats.syntax.all.*
|
||||||
|
import cats.effect.Async
|
||||||
|
import scala.collection.mutable.ListBuffer
|
||||||
|
import gs.test.v0.TestDefinition.Tag
|
||||||
|
import gs.test.v0.TestDefinition.Marker
|
||||||
|
import gs.test.v0.TestDefinition.Iterations
|
||||||
|
import cats.data.EitherT
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import scala.jdk.CollectionConverters.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for defining groups of related tests. Users should extend this
|
||||||
|
* class to define their tests.
|
||||||
|
*/
|
||||||
|
abstract class TestGroup[F[_]: Async]:
|
||||||
|
def name: String
|
||||||
|
def tags: List[Tag] = List.empty
|
||||||
|
def markers: List[Marker] = List.empty
|
||||||
|
def documentation: Option[String] = None
|
||||||
|
|
||||||
|
private var beforeGroupValue: Option[F[Unit]] = None
|
||||||
|
private var afterGroupValue: Option[F[Unit]] = None
|
||||||
|
private var beforeEachTestValue: Option[F[Unit]] = None
|
||||||
|
private var afterEachTestValue: Option[F[Unit]] = None
|
||||||
|
|
||||||
|
private val registry: TestGroup.Registry[F] = new TestGroup.Registry[F]
|
||||||
|
|
||||||
|
def toGroupDefinition(): TestGroupDefinition[F] =
|
||||||
|
new TestGroupDefinition[F](
|
||||||
|
name = TestGroupDefinition.Name(name),
|
||||||
|
documentation = documentation,
|
||||||
|
testTags = tags,
|
||||||
|
testMarkers = markers,
|
||||||
|
beforeGroup = beforeGroupValue,
|
||||||
|
afterGroup = afterGroupValue,
|
||||||
|
beforeEachTest = beforeEachTestValue,
|
||||||
|
afterEachTest = afterEachTestValue,
|
||||||
|
tests = registry.toList()
|
||||||
|
)
|
||||||
|
|
||||||
|
protected def beforeGroup(f: => F[Unit]): Unit =
|
||||||
|
beforeGroupValue = Some(f)
|
||||||
|
()
|
||||||
|
|
||||||
|
protected def afterGroup(f: => F[Unit]): Unit =
|
||||||
|
afterGroupValue = Some(f)
|
||||||
|
()
|
||||||
|
|
||||||
|
protected def beforeEachTest(f: => F[Unit]): Unit =
|
||||||
|
beforeEachTestValue = Some(f)
|
||||||
|
()
|
||||||
|
|
||||||
|
protected def afterEachTest(f: => F[Unit]): Unit =
|
||||||
|
afterEachTestValue = Some(f)
|
||||||
|
()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a new test.
|
||||||
|
*
|
||||||
|
* ## Required Information
|
||||||
|
*
|
||||||
|
* All tests require 3 things, at minimum:
|
||||||
|
*
|
||||||
|
* - [[PermanentId]]
|
||||||
|
* - Display Name
|
||||||
|
* - Unit of Work (the code to execute)
|
||||||
|
*
|
||||||
|
* The [[PermanentId]] must be unique within the [[TestSuite]].
|
||||||
|
*
|
||||||
|
* ## Default Values
|
||||||
|
*
|
||||||
|
* Tests iterate 1 time by default. Tags and Markers are inherited from the
|
||||||
|
* parent group. If this group contains tag "foo", any test within this group
|
||||||
|
* will also get tag "foo".
|
||||||
|
*
|
||||||
|
* @param permanentId The [[PermanentId]] for this test.
|
||||||
|
* @param name The display name for this test.
|
||||||
|
* @return A builder, to help complete test definition.
|
||||||
|
*/
|
||||||
|
protected def test(
|
||||||
|
permanentId: PermanentId,
|
||||||
|
name: String
|
||||||
|
): TestGroup.TestBuilder[F] =
|
||||||
|
new TestGroup.TestBuilder[F](
|
||||||
|
registry = registry,
|
||||||
|
name = TestDefinition.Name(name),
|
||||||
|
permanentId = permanentId,
|
||||||
|
tags = ListBuffer(tags*),
|
||||||
|
markers = ListBuffer(markers*),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
object TestGroup:
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specialization of [[TestGroup]] for `cats.effect.IO`, the typical use case.
|
||||||
|
*/
|
||||||
|
abstract class IO extends TestGroup[cats.effect.IO]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builder to assist with defining tests.
|
||||||
|
*
|
||||||
|
* @param registry Registry instance internal to a [[TestGroup]] for recording completed definitions.
|
||||||
|
* @param name The name of the test.
|
||||||
|
* @param permanentId The [[PermanentId]] of the test.
|
||||||
|
* @param tags List of [[TestDefinition.Tag]] applicable to this test.
|
||||||
|
* @param markers List of [[TestDefinition.Marker]] applicable to this test.
|
||||||
|
* @param documentation The documentation for this test.
|
||||||
|
* @param iterations Number of iterations to run this test.
|
||||||
|
*/
|
||||||
|
protected final class TestBuilder[F[_]: Async](
|
||||||
|
val registry: Registry[F],
|
||||||
|
val name: TestDefinition.Name,
|
||||||
|
val permanentId: PermanentId,
|
||||||
|
private val tags: ListBuffer[Tag],
|
||||||
|
private val markers: ListBuffer[Marker],
|
||||||
|
private var documentation: Option[String] = None,
|
||||||
|
private var iterations: TestDefinition.Iterations = Iterations.One,
|
||||||
|
):
|
||||||
|
/**
|
||||||
|
* Supply documentation for this test.
|
||||||
|
*
|
||||||
|
* @param docs The documentation for this test.
|
||||||
|
* @return This builder.
|
||||||
|
*/
|
||||||
|
def document(docs: String): TestBuilder[F] =
|
||||||
|
documentation = Some(docs)
|
||||||
|
this
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add additional [[Test.Tag]] to this test definition.
|
||||||
|
*
|
||||||
|
* @param additionalTags The list of new tags.
|
||||||
|
* @return This builder.
|
||||||
|
*/
|
||||||
|
def tagged(additionalTags: Tag*): TestBuilder[F] =
|
||||||
|
val _ = tags.addAll(additionalTags)
|
||||||
|
this
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the [[TestDefinition.Marker.Ignored]] marker to this test definition.
|
||||||
|
*
|
||||||
|
* @return This builder.
|
||||||
|
*/
|
||||||
|
def ignored(): TestBuilder[F] =
|
||||||
|
val _ = markers.addOne(Marker.Ignored)
|
||||||
|
this
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add one or more [[TestDefinition.Marker]] to this test definition.
|
||||||
|
*
|
||||||
|
* @param additionalMarkers The list of markers to add.
|
||||||
|
* @return This builder.
|
||||||
|
*/
|
||||||
|
def marked(additionalMarkers: Marker*): TestBuilder[F] =
|
||||||
|
val _ = markers.addAll(additionalMarkers)
|
||||||
|
this
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the number of times this test should iterate.
|
||||||
|
*
|
||||||
|
* @param iters The number of iterations.
|
||||||
|
* @return This builder.
|
||||||
|
*/
|
||||||
|
def iterate(iters: Iterations): TestBuilder[F] =
|
||||||
|
iterations = iters
|
||||||
|
this
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provide an input supplier for this test. Note that each iteration of the
|
||||||
|
* test results in the input function being evaluated.
|
||||||
|
*
|
||||||
|
* @param f The input function.
|
||||||
|
* @return Builder that supports input.
|
||||||
|
*/
|
||||||
|
def input[Input](f: F[Input]): InputTestBuilder[F, Input] =
|
||||||
|
new InputTestBuilder[F, Input](
|
||||||
|
registry = registry,
|
||||||
|
name = name,
|
||||||
|
permanentId = permanentId,
|
||||||
|
inputFunction = f,
|
||||||
|
tags = tags,
|
||||||
|
markers = markers,
|
||||||
|
iterations = iterations
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalize and register this test with a pure unit of work.
|
||||||
|
*
|
||||||
|
* @param unitOfWork The function this test will execute.
|
||||||
|
*/
|
||||||
|
def pure(unitOfWork: => Either[TestFailure, Unit]): Unit =
|
||||||
|
apply(EitherT.fromEither[F](unitOfWork))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalize and register this test with an effectful unit of work.
|
||||||
|
*
|
||||||
|
* @param unitOfWork The function this test will execute.
|
||||||
|
*/
|
||||||
|
def effectful(unitOfWork: => F[Either[TestFailure, Unit]]): Unit =
|
||||||
|
apply(EitherT(unitOfWork))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalize and register this test with an effectful unit of work.
|
||||||
|
*
|
||||||
|
* @param unitOfWork The function this test will execute.
|
||||||
|
*/
|
||||||
|
def apply(unitOfWork: => EitherT[F, TestFailure, Unit]): Unit =
|
||||||
|
registry.register(new TestDefinition[F](
|
||||||
|
name = name,
|
||||||
|
permanentId = permanentId,
|
||||||
|
documentation = documentation,
|
||||||
|
tags = tags.distinct.toList,
|
||||||
|
markers = markers.distinct.toList,
|
||||||
|
iterations = iterations,
|
||||||
|
unitOfWork = unitOfWork
|
||||||
|
))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builder to assist with defining tests. This builder is for tests which
|
||||||
|
* accept input via some producing function.
|
||||||
|
*
|
||||||
|
* @param registry Registry instance internal to a [[TestGroup]] for recording completed definitions.
|
||||||
|
* @param name The name of the test.
|
||||||
|
* @param permanentId The [[PermanentId]] of the test.
|
||||||
|
* @param inputFunction The function that provides input to this test.
|
||||||
|
* @param tags List of [[TestDefinition.Tag]] applicable to this test.
|
||||||
|
* @param markers List of [[TestDefinition.Marker]] applicable to this test.
|
||||||
|
* @param documentation The documentation for this test.
|
||||||
|
* @param iterations Number of iterations to run this test.
|
||||||
|
*/
|
||||||
|
protected final class InputTestBuilder[F[_]: Async, Input](
|
||||||
|
val registry: Registry[F],
|
||||||
|
val name: TestDefinition.Name,
|
||||||
|
val permanentId: PermanentId,
|
||||||
|
val inputFunction: F[Input],
|
||||||
|
private val tags: ListBuffer[Tag],
|
||||||
|
private val markers: ListBuffer[Marker],
|
||||||
|
private var documentation: Option[String] = None,
|
||||||
|
private var iterations: TestDefinition.Iterations = Iterations.One,
|
||||||
|
):
|
||||||
|
/**
|
||||||
|
* Supply documentation for this test.
|
||||||
|
*
|
||||||
|
* @param docs The documentation for this test.
|
||||||
|
* @return This builder.
|
||||||
|
*/
|
||||||
|
def document(docs: String): InputTestBuilder[F, Input] =
|
||||||
|
documentation = Some(docs)
|
||||||
|
this
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add additional [[Test.Tag]] to this test definition.
|
||||||
|
*
|
||||||
|
* @param additionalTags The list of new tags.
|
||||||
|
* @return This builder.
|
||||||
|
*/
|
||||||
|
def tagged(additionalTags: Tag*): InputTestBuilder[F, Input] =
|
||||||
|
val _ = tags.addAll(additionalTags)
|
||||||
|
this
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the [[TestDefinition.Marker.Ignored]] marker to this test definition.
|
||||||
|
*
|
||||||
|
* @return This builder.
|
||||||
|
*/
|
||||||
|
def ignored(): InputTestBuilder[F, Input] =
|
||||||
|
val _ = markers.addOne(Marker.Ignored)
|
||||||
|
this
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add one or more [[TestDefinition.Marker]] to this test definition.
|
||||||
|
*
|
||||||
|
* @param additionalMarkers The list of markers to add.
|
||||||
|
* @return This builder.
|
||||||
|
*/
|
||||||
|
def marked(additionalMarkers: Marker*): InputTestBuilder[F, Input] =
|
||||||
|
val _ = markers.addAll(additionalMarkers)
|
||||||
|
this
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the number of times this test should iterate.
|
||||||
|
*
|
||||||
|
* @param iters The number of iterations.
|
||||||
|
* @return This builder.
|
||||||
|
*/
|
||||||
|
def iterate(iters: Iterations): InputTestBuilder[F, Input] =
|
||||||
|
iterations = iters
|
||||||
|
this
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalize and register this test with a pure unit of work.
|
||||||
|
*
|
||||||
|
* @param unitOfWork The function this test will execute.
|
||||||
|
*/
|
||||||
|
def pure(unitOfWork: Input => Either[TestFailure, Unit]): Unit =
|
||||||
|
apply(input => EitherT(Async[F].delay(unitOfWork(input))))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalize and register this test with an effectful unit of work.
|
||||||
|
*
|
||||||
|
* @param unitOfWork The function this test will execute.
|
||||||
|
*/
|
||||||
|
def effectful(unitOfWork: Input => F[Either[TestFailure, Unit]]): Unit =
|
||||||
|
apply(input => EitherT(unitOfWork(input)))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalize and register this test with an effectful unit of work.
|
||||||
|
*
|
||||||
|
* @param unitOfWork The function this test will execute.
|
||||||
|
*/
|
||||||
|
def apply(unitOfWork: Input => EitherT[F, TestFailure, Unit]): Unit =
|
||||||
|
registry.register(new TestDefinition[F](
|
||||||
|
name = name,
|
||||||
|
permanentId = permanentId,
|
||||||
|
documentation = documentation,
|
||||||
|
tags = tags.distinct.toList,
|
||||||
|
markers = markers.distinct.toList,
|
||||||
|
iterations = iterations,
|
||||||
|
unitOfWork = EitherT.right(inputFunction).flatMap(unitOfWork)
|
||||||
|
))
|
||||||
|
|
||||||
|
protected final class Registry[F[_]]:
|
||||||
|
val mapping: ConcurrentHashMap[PermanentId, TestDefinition[F]] =
|
||||||
|
new ConcurrentHashMap[PermanentId, TestDefinition[F]]
|
||||||
|
|
||||||
|
def register(test: TestDefinition[F]): Unit =
|
||||||
|
if mapping.contains(test.permanentId) then
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
s"Attempted to register test with duplicate Permanent ID '${test.permanentId.show}'."
|
||||||
|
)
|
||||||
|
else
|
||||||
|
mapping.put(test.permanentId, test)
|
||||||
|
|
||||||
|
def toList(): List[TestDefinition[F]] = mapping.values().asScala.toList
|
||||||
|
|
||||||
|
end Registry
|
||||||
|
|
||||||
|
end TestGroup
|
|
@ -0,0 +1,51 @@
|
||||||
|
package gs.test.v0
|
||||||
|
|
||||||
|
import cats.Show
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Each group is comprised of a list of [[Test]]. This list may be empty.
|
||||||
|
*
|
||||||
|
* Groups are essentially metadata for tests for viewing/organization purposes.
|
||||||
|
*
|
||||||
|
* @param name The group name. Not considered to be unique.
|
||||||
|
* @param documentation Arbitrary documentation for this group of tests.
|
||||||
|
* @param testTags Set of tags applied to all [[Test]] within the group.
|
||||||
|
* @param tests The list of tests in this group.
|
||||||
|
*/
|
||||||
|
final class TestGroupDefinition[F[_]](
|
||||||
|
val name: TestGroupDefinition.Name,
|
||||||
|
val documentation: Option[String],
|
||||||
|
val testTags: List[TestDefinition.Tag],
|
||||||
|
val testMarkers: List[TestDefinition.Marker],
|
||||||
|
val beforeGroup: Option[F[Unit]],
|
||||||
|
val afterGroup: Option[F[Unit]],
|
||||||
|
val beforeEachTest: Option[F[Unit]],
|
||||||
|
val afterEachTest: Option[F[Unit]],
|
||||||
|
val tests: List[TestDefinition[F]]
|
||||||
|
)
|
||||||
|
|
||||||
|
object TestGroupDefinition:
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opaque type representing names that may be assigned to [[TestGroup]].
|
||||||
|
*/
|
||||||
|
opaque type Name = String
|
||||||
|
|
||||||
|
object Name:
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instantiate a new [[TestGroup.Name]]. This name is not unique, has no
|
||||||
|
* constraints, and only exists for display purposes.
|
||||||
|
*
|
||||||
|
* @param name The candidate string.
|
||||||
|
* @return The new [[TestGroup.Name]] instance.
|
||||||
|
*/
|
||||||
|
def apply(name: String): Name = name
|
||||||
|
|
||||||
|
given CanEqual[Name, Name] = CanEqual.derived
|
||||||
|
|
||||||
|
given Show[Name] = name => name
|
||||||
|
|
||||||
|
end Name
|
||||||
|
|
||||||
|
end TestGroupDefinition
|
19
modules/core/src/main/scala/gs/test/v0/TestSuite.scala
Normal file
19
modules/core/src/main/scala/gs/test/v0/TestSuite.scala
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
package gs.test.v0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Test Suite is the primary unit of organization within `gs-test` -- each
|
||||||
|
* execution _typically_ runs a single test suite. For example, the unit tests
|
||||||
|
* for some project would likely comprise of a single suite.
|
||||||
|
*
|
||||||
|
* Within each suite is a list of [[TestGroup]], arbitrary ways to organize
|
||||||
|
* individual [[Test]] definitions.
|
||||||
|
*
|
||||||
|
* @param name The name of this test suite.
|
||||||
|
* @param documentation Arbitrary documentation for this suite of tests.
|
||||||
|
* @param groups List of [[TestGroup]] owned by this suite.
|
||||||
|
*/
|
||||||
|
case class TestSuite[F[_]](
|
||||||
|
name: String,
|
||||||
|
documentation: Option[String],
|
||||||
|
groups: List[TestGroupDefinition[F]]
|
||||||
|
)
|
|
@ -0,0 +1,45 @@
|
||||||
|
package gs.test.v0
|
||||||
|
|
||||||
|
import munit.*
|
||||||
|
|
||||||
|
class GroupImplementationTests extends FunSuite:
|
||||||
|
import GroupImplementationTests.*
|
||||||
|
|
||||||
|
test("should support a group with a simple, pure, test") {
|
||||||
|
val g1 = new G1
|
||||||
|
val group = g1.toGroupDefinition()
|
||||||
|
assertEquals(group.name, TestGroupDefinition.Name("G1"))
|
||||||
|
assertEquals(group.documentation, None)
|
||||||
|
assertEquals(group.testTags, List.empty)
|
||||||
|
assertEquals(group.testMarkers, List.empty)
|
||||||
|
assertEquals(group.beforeGroup, None)
|
||||||
|
assertEquals(group.afterGroup, None)
|
||||||
|
assertEquals(group.beforeEachTest, None)
|
||||||
|
assertEquals(group.afterEachTest, None)
|
||||||
|
assertEquals(group.tests.size, 1)
|
||||||
|
|
||||||
|
group.tests match
|
||||||
|
case t :: Nil =>
|
||||||
|
assertEquals(t.name, TestDefinition.Name("simple"))
|
||||||
|
assertEquals(t.permanentId, Ids.T1)
|
||||||
|
assertEquals(t.documentation, None)
|
||||||
|
assertEquals(t.tags, List.empty)
|
||||||
|
assertEquals(t.markers, List.empty)
|
||||||
|
assertEquals(t.iterations, TestDefinition.Iterations.One)
|
||||||
|
case _ => fail("Unexpected number of defined tests.")
|
||||||
|
}
|
||||||
|
|
||||||
|
object GroupImplementationTests:
|
||||||
|
|
||||||
|
object Ids:
|
||||||
|
|
||||||
|
val T1: PermanentId = PermanentId("t1")
|
||||||
|
|
||||||
|
end Ids
|
||||||
|
|
||||||
|
class G1 extends TestGroup.IO:
|
||||||
|
override def name: String = "G1"
|
||||||
|
test(Ids.T1, "simple").pure { Right(()) }
|
||||||
|
end G1
|
||||||
|
|
||||||
|
end GroupImplementationTests
|
1
project/build.properties
Normal file
1
project/build.properties
Normal file
|
@ -0,0 +1 @@
|
||||||
|
sbt.version=1.10.1
|
33
project/plugins.sbt
Normal file
33
project/plugins.sbt
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
def selectCredentials(): Credentials =
|
||||||
|
if ((Path.userHome / ".sbt" / ".credentials").exists())
|
||||||
|
Credentials(Path.userHome / ".sbt" / ".credentials")
|
||||||
|
else
|
||||||
|
Credentials.apply(
|
||||||
|
realm = "Reposilite",
|
||||||
|
host = "maven.garrity.co",
|
||||||
|
userName = sys.env
|
||||||
|
.get("GS_MAVEN_USER")
|
||||||
|
.getOrElse(
|
||||||
|
throw new RuntimeException(
|
||||||
|
"You must either provide ~/.sbt/.credentials or specify the GS_MAVEN_USER environment variable."
|
||||||
|
)
|
||||||
|
),
|
||||||
|
passwd = sys.env
|
||||||
|
.get("GS_MAVEN_TOKEN")
|
||||||
|
.getOrElse(
|
||||||
|
throw new RuntimeException(
|
||||||
|
"You must either provide ~/.sbt/.credentials or specify the GS_MAVEN_TOKEN environment variable."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
credentials += selectCredentials()
|
||||||
|
|
||||||
|
externalResolvers := Seq(
|
||||||
|
"Garrity Software Mirror" at "https://maven.garrity.co/releases",
|
||||||
|
"Garrity Software Releases" at "https://maven.garrity.co/gs"
|
||||||
|
)
|
||||||
|
|
||||||
|
addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.1.0")
|
||||||
|
addSbtPlugin("gs" % "sbt-garrity-software" % "0.3.0")
|
||||||
|
addSbtPlugin("gs" % "sbt-gs-semver" % "0.3.0")
|
Loading…
Add table
Reference in a new issue