├── project ├── build.properties └── plugins.sbt ├── consume ├── project │ ├── build.properties │ └── plugins.sbt ├── README.md ├── src │ └── test │ │ └── scala │ │ └── ATest.scala └── build.sbt ├── site ├── themes │ └── intent │ │ ├── archetypes │ │ └── default.md │ │ ├── layouts │ │ ├── _default │ │ │ ├── list.html │ │ │ ├── home.html │ │ │ └── single.html │ │ ├── partials │ │ │ ├── tail.html │ │ │ ├── head.html │ │ │ └── sidemenu.html │ │ ├── shortcodes │ │ │ └── intent.html │ │ ├── index.html │ │ └── 404.html │ │ ├── static │ │ ├── intent-logo.png │ │ ├── prism.intent.css │ │ ├── intent.css │ │ └── prism.js │ │ └── theme.toml ├── archetypes │ └── default.md ├── README.md ├── content │ ├── types-of-tests │ │ ├── stateless.md │ │ ├── table-driven.md │ │ ├── asynchronous.md │ │ └── stateful.md │ ├── customization.md │ ├── _index.md │ └── matchers.md └── config.toml ├── docs ├── intent-logo.png ├── categories │ ├── index.xml │ └── index.html ├── types-of-tests │ ├── index.html │ ├── index.xml │ └── stateless │ │ └── index.html ├── sitemap.xml ├── prism.intent.css ├── intent.css ├── index.xml ├── 404.html └── prism.js ├── CONTRIBUTORS.md ├── src ├── test │ └── scala │ │ └── intent │ │ ├── testdata │ │ ├── EmtpyTestSuite.scala │ │ ├── SingleLevelTestSuite.scala │ │ └── NestedTestsSuite.scala │ │ ├── styles │ │ ├── StatelessTest.scala │ │ ├── StatefulTest.scala │ │ ├── TableDrivenTest.scala │ │ └── AsyncTest.scala │ │ ├── matchers │ │ ├── ToMatchTest.scala │ │ ├── ToCompleteWithTest.scala │ │ ├── ToHaveLengthTest.scala │ │ ├── ExpectTest.scala │ │ ├── ToContainAllOfTest.scala │ │ ├── ToContainTest.scala │ │ ├── FailureTest.scala │ │ ├── ToThrowTest.scala │ │ └── ToEqualTest.scala │ │ ├── sbt │ │ └── IgnoredTest.scala │ │ ├── suite │ │ └── TestDiscoveryTest.scala │ │ ├── formatters │ │ └── FormatTest.scala │ │ ├── helpers │ │ ├── TestSuiteRunnerTester.scala │ │ └── Meta.scala │ │ ├── docs │ │ └── HowToAssert.scala │ │ ├── util │ │ └── DelayedFutureTest.scala │ │ └── runner │ │ ├── FocusedTest.scala │ │ └── TestSuiteRunnerTest.scala └── main │ └── scala │ └── intent │ ├── core │ ├── core.scala │ ├── descriptiveness.scala │ ├── observable.scala │ ├── expectations │ │ ├── util.scala │ │ ├── size.scala │ │ ├── match.scala │ │ ├── future.scala │ │ ├── throw.scala │ │ ├── equal.scala │ │ └── contain.scala │ ├── structure.scala │ ├── formatters.scala │ ├── equality.scala │ └── expect.scala │ ├── external.scala │ ├── util │ └── futures.scala │ ├── runner │ └── TestSuiteRunner.scala │ └── sbt │ └── Runner.scala ├── indent.sh ├── .github └── workflows │ ├── nightly.yml │ └── build.yml ├── CONTRIBUTING.md ├── sonatype.sbt ├── CHANGELOG.md ├── .gitignore ├── README.md ├── macros └── src │ └── main │ └── scala │ └── intent │ └── macros │ └── source.scala └── old-docs ├── running-tests.md └── how-to-assert.md /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.3.2 2 | -------------------------------------------------------------------------------- /consume/project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.3.2 2 | -------------------------------------------------------------------------------- /site/themes/intent/archetypes/default.md: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | --- 4 | -------------------------------------------------------------------------------- /consume/project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("ch.epfl.lamp" % "sbt-dotty" % "0.3.4") -------------------------------------------------------------------------------- /docs/intent-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/factor10/intent/HEAD/docs/intent-logo.png -------------------------------------------------------------------------------- /site/themes/intent/layouts/_default/list.html: -------------------------------------------------------------------------------- 1 | 2 | {{ partial "head.html" . }} 3 | {{ partial "tail.html" . }} -------------------------------------------------------------------------------- /site/archetypes/default.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "{{ replace .Name "-" " " | title }}" 3 | date: {{ .Date }} 4 | --- 5 | 6 | -------------------------------------------------------------------------------- /consume/README.md: -------------------------------------------------------------------------------- 1 | SBT test project that consumes intent and sets it up as a test 2 | framework. 3 | 4 | $ sbt test 5 | -------------------------------------------------------------------------------- /site/themes/intent/static/intent-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/factor10/intent/HEAD/site/themes/intent/static/intent-logo.png -------------------------------------------------------------------------------- /site/themes/intent/layouts/partials/tail.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | # Intent contributors 2 | 3 | * [Jamie Thompson](https://github.com/bishabosha) 4 | * Upgrade to new Dotty versions 5 | -------------------------------------------------------------------------------- /site/themes/intent/layouts/shortcodes/intent.html: -------------------------------------------------------------------------------- 1 |
2 | See {{ index .Params 0 }} 3 |
-------------------------------------------------------------------------------- /src/test/scala/intent/testdata/EmtpyTestSuite.scala: -------------------------------------------------------------------------------- 1 | package intent.testdata 2 | 3 | import intent.Stateless 4 | 5 | class EmtpyTestSuite extends Stateless 6 | -------------------------------------------------------------------------------- /consume/src/test/scala/ATest.scala: -------------------------------------------------------------------------------- 1 | import intent._ 2 | 3 | 4 | class ATest extends TestSuite with Stateless: 5 | "3 + 3": 6 | "should be 6" in expect(3 + 3).toEqual(6) 7 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("ch.epfl.lamp" % "sbt-dotty" % "0.3.4") 2 | addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "3.8") 3 | addSbtPlugin("com.jsuereth" % "sbt-pgp" % "2.0.0-M2") -------------------------------------------------------------------------------- /src/test/scala/intent/styles/StatelessTest.scala: -------------------------------------------------------------------------------- 1 | package intent.styles 2 | 3 | import intent._ 4 | 5 | class StatelessTest extends TestSuite with Stateless: 6 | "a stateless test": 7 | "works well" in (expect(1) toEqual 1) 8 | -------------------------------------------------------------------------------- /src/test/scala/intent/testdata/SingleLevelTestSuite.scala: -------------------------------------------------------------------------------- 1 | package intent.testdata 2 | 3 | import intent.Stateless 4 | 5 | class SingleLevelTestSuite extends Stateless: 6 | "root suite": 7 | "root test" in expect( 1 + 1 ).toEqual(2) 8 | -------------------------------------------------------------------------------- /indent.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ti=~/.ivy2/cache/org.scala-sbt/test-interface/jars/test-interface-1.0.jar 4 | find . -name '*.scala' | xargs dotc -classpath $ti -rewrite -new-syntax 5 | find . -name '*.scala' | xargs dotc -classpath $ti -rewrite -indent 6 | -------------------------------------------------------------------------------- /src/main/scala/intent/core/core.scala: -------------------------------------------------------------------------------- 1 | package intent.core 2 | 3 | import scala.collection.mutable.{ Map => MMap } 4 | import scala.collection.immutable.{ Map => IMap } 5 | import scala.collection.Map 6 | 7 | type MapLike[K, V] = Map[K, V] | IMap[K, V] | MMap[K, V] -------------------------------------------------------------------------------- /site/themes/intent/theme.toml: -------------------------------------------------------------------------------- 1 | name = "intent" 2 | license = "MIT" 3 | licenselink = "https://github.com/factor10/intent/LICENCE.md" 4 | description = "Theme for Intent documentation" 5 | homepage = "https://factor10.github.io/intent" 6 | tags = ["", ""] 7 | features = ["", ""] 8 | min_version = "0.54.0" 9 | -------------------------------------------------------------------------------- /src/main/scala/intent/core/descriptiveness.scala: -------------------------------------------------------------------------------- 1 | package intent.core 2 | 3 | import intent.macros.Position 4 | 5 | object PositionDescription: 6 | def (position: Position) contextualize (desc: String) = 7 | s"${desc} (${position.filePath}:${position.lineNumber0 + 1}:${position.columnNumber0 + 1})" 8 | -------------------------------------------------------------------------------- /src/main/scala/intent/core/observable.scala: -------------------------------------------------------------------------------- 1 | package intent.core 2 | 3 | /** 4 | * Used as callback when running a test suite, for reporting test events. 5 | * Originally part of a simple Observable pattern implementation, but this 6 | * is all we need right now. 7 | */ 8 | trait Subscriber[T]: 9 | def onNext(event: T): Unit 10 | -------------------------------------------------------------------------------- /site/README.md: -------------------------------------------------------------------------------- 1 | Intent site and documentation is generated using Hugo as the static site generator. 2 | 3 | Download and install from https://gohugo.io/ 4 | 5 | 6 | To run and view the content of the files locally: 7 | 8 | hugo server 9 | 10 | To generate the documentation wich will be served: 11 | 12 | hugo --destination ../docs 13 | -------------------------------------------------------------------------------- /src/test/scala/intent/matchers/ToMatchTest.scala: -------------------------------------------------------------------------------- 1 | package intent.matchers 2 | 3 | import intent.{Stateless, TestSuite} 4 | 5 | class ToMatchTest extends TestSuite with Stateless: 6 | "toMatch": 7 | "for String": 8 | "should find match" in expect("foobar").toMatch("^foo".r) 9 | 10 | "should find non-match" in expect("foobar").not.toMatch("foo$".r) 11 | -------------------------------------------------------------------------------- /src/test/scala/intent/testdata/NestedTestsSuite.scala: -------------------------------------------------------------------------------- 1 | package intent.testdata 2 | 3 | import intent.Stateless 4 | 5 | class NestedTestsSuite extends Stateless: 6 | "root suite": 7 | "child suite": 8 | "grand child suite": 9 | "grand child test" in expect( 1 + 1 ).toEqual(2) 10 | 11 | "child test" in expect( 1 + 1 ).toEqual(2) 12 | 13 | "root test" in expect( 1 + 1 ).toEqual(2) 14 | -------------------------------------------------------------------------------- /src/test/scala/intent/sbt/IgnoredTest.scala: -------------------------------------------------------------------------------- 1 | package intent.sbt 2 | 3 | import intent.{TestSuite, State, Stateless} 4 | 5 | // The ignored is used to manually verify that SBT reports ignored tests using correct 6 | // and summary. The actual runner and result are tested with other unit-tests. 7 | 8 | class IgnoredTest extends TestSuite with Stateless: 9 | "ignored test" ignore: 10 | fail("This should never fail, only be ignored!") 11 | -------------------------------------------------------------------------------- /site/themes/intent/layouts/index.html: -------------------------------------------------------------------------------- 1 | {{ partial "head.html" . }} 2 | 3 |
4 | {{ partial "sidemenu.html" . }} 5 | 6 |
7 |
8 |
9 | {{ .Content }} 10 |
11 |
12 |
13 |
14 | 15 | {{ partial "tail.html" . }} 16 | -------------------------------------------------------------------------------- /site/themes/intent/layouts/404.html: -------------------------------------------------------------------------------- 1 | {{ partial "head.html" . }} 2 | 3 |
4 | {{ partial "sidemenu.html" . }} 5 | 6 |
7 |
8 |
9 |

Page not found

10 |
11 |
12 |
13 |
14 | 15 | {{ partial "tail.html" . }} 16 | -------------------------------------------------------------------------------- /site/themes/intent/layouts/_default/home.html: -------------------------------------------------------------------------------- 1 | {{ partial "head.html" . }} 2 | 3 |
4 | {{ partial "sidemenu.html" . }} 5 | 6 |
7 |
8 |
9 | {{ .Content }} 10 |
11 |
12 |
13 |
14 | 15 | {{ partial "tail.html" . }} 16 | -------------------------------------------------------------------------------- /site/themes/intent/layouts/_default/single.html: -------------------------------------------------------------------------------- 1 | {{ partial "head.html" . }} 2 | 3 |
4 | {{ partial "sidemenu.html" . }} 5 | 6 |
7 |
8 |
9 | {{ .Content }} 10 |
11 |
12 |
13 |
14 | 15 | {{ partial "tail.html" . }} 16 | -------------------------------------------------------------------------------- /src/test/scala/intent/matchers/ToCompleteWithTest.scala: -------------------------------------------------------------------------------- 1 | package intent.matchers 2 | 3 | import intent.{Stateless, TestSuite} 4 | import scala.concurrent.Future 5 | 6 | class ToCompleteWithTest extends TestSuite with Stateless: 7 | "toCompleteWith": 8 | "for successful Future": 9 | "should be completed" in expect(Future.successful("foo")).toCompleteWith("foo") 10 | "can be negated" in expect(Future.successful("foo")).not.toCompleteWith("bar") 11 | -------------------------------------------------------------------------------- /.github/workflows/nightly.yml: -------------------------------------------------------------------------------- 1 | name: Nightly build 2 | 3 | # Build nightly at 0100 UTC 4 | on: 5 | schedule: 6 | - cron: "0 1 * * *" 7 | 8 | jobs: 9 | build: 10 | 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: Set up JDK 1.8 16 | uses: actions/setup-java@v1 17 | with: 18 | java-version: 1.8 19 | 20 | - name: Compiling 21 | run: sbt compile 22 | - name: Testing 23 | run: sbt test 24 | -------------------------------------------------------------------------------- /consume/build.sbt: -------------------------------------------------------------------------------- 1 | val dottyVersion = "0.27.0-RC1" 2 | 3 | ThisBuild / name := "consume" 4 | ThisBuild / version := "0.0.1" 5 | ThisBuild / scalaVersion := dottyVersion 6 | 7 | lazy val root = project 8 | .in(file(".")) 9 | .settings( 10 | name := "consume-intent", 11 | organization := "com.factor10", 12 | scalacOptions += "-Yindent-colons", 13 | libraryDependencies += "com.factor10" %% "intent" % "0.6.0", 14 | testFrameworks += new TestFramework("intent.sbt.Framework") 15 | ) 16 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Intent 2 | 3 | The design of Intent and the structure of tests are still moving targets. 4 | Therefore, if you wish to contribute, please open an issue or comment on an 5 | existing issue so that we can have a discussion first. 6 | 7 | For any contribution, the following applies: 8 | 9 | * Tests must be added, if relevant. 10 | * Documentation must be added, if relevant. 11 | * In the absence of style guidelines, please stick to the existing style. 12 | If unsure what the existing style is, ask! :) 13 | -------------------------------------------------------------------------------- /src/test/scala/intent/matchers/ToHaveLengthTest.scala: -------------------------------------------------------------------------------- 1 | package intent.matchers 2 | 3 | import intent.{Stateless, TestSuite} 4 | 5 | class ToHaveLengthTest extends TestSuite with Stateless: 6 | "toHaveLength": 7 | "empty list should have length 0" in expect(Seq()).toHaveLength(0) 8 | 9 | "Seq(1) should have length 1" in expect(Seq(1)).toHaveLength(1) 10 | 11 | "Nested Seq(Seq(), Seq()) should have length 2" in expect(Seq(Seq(), Seq())).toHaveLength(2) 12 | 13 | "Seq() should *not* have length 1" in expect(Seq()).not.toHaveLength(1) 14 | -------------------------------------------------------------------------------- /src/test/scala/intent/styles/StatefulTest.scala: -------------------------------------------------------------------------------- 1 | package intent.styles 2 | 3 | import intent._ 4 | 5 | class StatefulTest extends TestSuite with State[StatefulState]: 6 | "root context" using StatefulState() to: 7 | "transforms a little" using (_.addOne()) to: 8 | "to transform some more" using (_.addOne()) to: 9 | "check the stuff" in: 10 | state => expect(state.stuff).toHaveLength(2) 11 | 12 | case class StatefulState(stuff: Seq[String] = Seq.empty): 13 | def addOne(): StatefulState = copy(stuff = stuff :+ "one more") 14 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | # Build on pushes to master or PR that targets master 4 | on: 5 | push: 6 | branches: 7 | - master 8 | pull_request: 9 | branches: 10 | - master 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v1 19 | - name: Set up JDK 1.8 20 | uses: actions/setup-java@v1 21 | with: 22 | java-version: 1.8 23 | 24 | - name: Compiling 25 | run: sbt compile 26 | - name: Testing 27 | run: sbt test 28 | -------------------------------------------------------------------------------- /src/test/scala/intent/styles/TableDrivenTest.scala: -------------------------------------------------------------------------------- 1 | package intent.styles 2 | 3 | import intent._ 4 | 5 | class TableDrivenTest extends TestSuite with State[TableState]: 6 | "Table-driven test" usingTable (myTable) to: 7 | "uses table data" in: 8 | s => 9 | expect(s.a + s.b).toEqual(s.sum) 10 | 11 | def myTable: Seq[TableState] = Seq( 12 | TableState(1, 2, 3), 13 | TableState(2, 3, 5), 14 | TableState(-1, -2, -3) 15 | ) 16 | 17 | case class TableState(a: Int, b: Int, sum: Int): 18 | override def toString = s"$a + $b should be $sum" 19 | -------------------------------------------------------------------------------- /site/themes/intent/layouts/partials/head.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ .Title }} · {{ .Site.Title }} 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/main/scala/intent/core/expectations/util.scala: -------------------------------------------------------------------------------- 1 | package intent.core.expectations 2 | 3 | private[expectations] def listTypeName[T](actual: IterableOnce[T]): String = 4 | Option(actual).map(_.getClass) match 5 | case Some(c) if classOf[List[_]].isAssignableFrom(c) => "List" 6 | case Some(c) if classOf[scala.collection.mutable.ArraySeq[_]].isAssignableFrom(c) => "Array" 7 | case Some(c) => c.getSimpleName 8 | // Null is an edge case, I think. If it turns out not to be, then we could take 9 | // the list type with ClassTag, possibly. 10 | case None => "" 11 | -------------------------------------------------------------------------------- /docs/categories/index.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Categories on Intent 5 | https://factor10.github.io/intent/categories/ 6 | Recent content in Categories on Intent 7 | Hugo -- gohugo.io 8 | en-us 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/main/scala/intent/external.scala: -------------------------------------------------------------------------------- 1 | package intent 2 | 3 | import scala.concurrent.duration._ 4 | import scala.concurrent.{ExecutionContext, Future} 5 | 6 | /** 7 | * Each test suite must be derived from this class in order to be discovered by the 8 | * test runner. 9 | */ 10 | abstract class TestSuite extends core.TestSuite 11 | 12 | trait State[TState] extends core.IntentStateSyntax[TState] with core.TestSupport 13 | 14 | trait AsyncState[TState] extends core.IntentAsyncStateSyntax[TState] with core.TestSupport 15 | 16 | trait Stateless extends core.IntentStatelessSyntax with core.TestSupport 17 | -------------------------------------------------------------------------------- /sonatype.sbt: -------------------------------------------------------------------------------- 1 | sonatypeProfileName := "com.factor10" 2 | publishMavenStyle := true 3 | licenses := Seq("APL2" -> url("http://www.apache.org/licenses/LICENSE-2.0.txt")) 4 | 5 | import xerial.sbt.Sonatype._ 6 | sonatypeProjectHosting := Some(GitHubHosting("factor10", "intent", "markus.eliasson@factor10.com")) 7 | 8 | developers := List( 9 | Developer(id = "eliasson", name = "Markus Eliasson", email = "markus.eliasson@gmail.com", url = url("http://markuseliasson.se")), 10 | Developer(id = "provegard", name = "Per Rovegård", email = "per@rovegard.se", url = url("http://www.programmaticallyspeaking.com")) 11 | ) 12 | -------------------------------------------------------------------------------- /src/test/scala/intent/matchers/ExpectTest.scala: -------------------------------------------------------------------------------- 1 | package intent.matchers 2 | 3 | import intent._ 4 | 5 | import scala.concurrent.Future 6 | 7 | class ExpectTest extends TestSuite with Stateless: 8 | "an expectation": 9 | "can be negated" in expect(1 + 2).not.toEqual(4) 10 | 11 | "can check a Future" in: 12 | val f = Future { 1 + 2 } 13 | expect(f).toCompleteWith(3) 14 | 15 | "can check a Seq" in: 16 | val s = Seq(1, 2, 3) 17 | expect(s).toContain(2) 18 | 19 | "can check a List" in: 20 | val l = List(1, 2, 3) 21 | expect(l).not.toContain(4) 22 | 23 | "can pass using successful" in success() 24 | -------------------------------------------------------------------------------- /docs/categories/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Categories · Intent 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /docs/types-of-tests/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Types-of-tests · Intent 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/main/scala/intent/core/expectations/size.scala: -------------------------------------------------------------------------------- 1 | package intent.core.expectations 2 | 3 | import intent.core._ 4 | import scala.concurrent.Future 5 | 6 | class LengthExpectation[T](expect: Expect[IterableOnce[T]], expected: Int) extends Expectation: 7 | def evaluate(): Future[ExpectationResult] = 8 | val actual = expect.evaluate() 9 | val actualLength = actual.iterator.size 10 | var r = if expect.isNegated && actualLength == expected then expect.fail(s"Expected size *not* to be $expected but was $actualLength") 11 | else if expect.isNegated && actualLength != expected then expect.pass 12 | else if actualLength != expected then expect.fail(s"Expected size to be $expected but was $actualLength") 13 | else expect.pass 14 | 15 | Future.successful(r) 16 | -------------------------------------------------------------------------------- /src/main/scala/intent/core/expectations/match.scala: -------------------------------------------------------------------------------- 1 | package intent.core.expectations 2 | 3 | import intent.core._ 4 | import scala.concurrent.Future 5 | import scala.util.matching.Regex 6 | 7 | class MatchExpectation[T](expect: Expect[String], re: Regex)(using fmt: Formatter[String]) extends Expectation: 8 | def evaluate(): Future[ExpectationResult] = 9 | val actual = expect.evaluate() 10 | 11 | var comparisonResult = re.findFirstIn(actual).isDefined 12 | if expect.isNegated then comparisonResult = !comparisonResult 13 | 14 | val r = if !comparisonResult then 15 | val actualStr = fmt.format(actual) 16 | val expectedStr = re.toString 17 | 18 | val desc = if expect.isNegated then 19 | s"Expected $actualStr not to match /$expectedStr/" 20 | else 21 | s"Expected $actualStr to match /$expectedStr/" 22 | expect.fail(desc) 23 | else expect.pass 24 | Future.successful(r) 25 | -------------------------------------------------------------------------------- /site/content/types-of-tests/stateless.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Table-driven tests" 3 | date: 2020-04-09T13:44:39+02:00 4 | draft: false 5 | --- 6 | 7 | # Table-driven tests 8 | 9 | A stateless test suite extends the `Statesless` suite. _Contexts_ in this style 10 | serve no other purpose than grouping tests into logical units. 11 | 12 | Consider the following example: 13 | 14 | ```scala 15 | import intent.{Stateless, TestSuite} 16 | 17 | class CalculatorTest extends TestSuite with Stateless: 18 | "A calculator" : 19 | "can add" : 20 | "plain numbers" in expect(Calculator().add(2, 4)).toEqual(6) 21 | "complex numbers" in: 22 | val a = Complex(2, 3) 23 | val b = Complex(3, 4) 24 | expect(Calculator().add(a, b)).toEqual(Complex(5, 7)) 25 | "can multiply" : 26 | "plain numbers" in expect(Calculator().multiply(2, 4)).toEqual(8) 27 | ``` 28 | 29 | Here, contexts serve to group tests based on the arithmetical operation used. 30 | -------------------------------------------------------------------------------- /src/test/scala/intent/suite/TestDiscoveryTest.scala: -------------------------------------------------------------------------------- 1 | package intent.suite 2 | 3 | import intent.{TestSuite, State, Stateless} 4 | import intent.testdata._ 5 | 6 | class TestDiscoveryTest extends TestSuite with State[TestDiscoveryTestState]: 7 | "Test discovery" using TestDiscoveryTestState() to: 8 | "empty test suite" using (_.withSuite(EmtpyTestSuite())) to: 9 | 10 | "should have 0 tests" in: 11 | st => expect(st.suite.allTestCases).toHaveLength(0) 12 | 13 | "single level test suite" using (_.withSuite(SingleLevelTestSuite())) to: 14 | 15 | "should have 1 tests" in: 16 | st => expect(st.suite.allTestCases).toHaveLength(1) 17 | 18 | "nested test suite" using (_.withSuite(NestedTestsSuite())) to: 19 | 20 | "should have 3 tests" in: 21 | st => expect(st.suite.allTestCases).toHaveLength(3) 22 | 23 | case class TestDiscoveryTestState(suite: Stateless = null): 24 | def withSuite(s: Stateless) = copy(suite = s) 25 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.6.0 (2020-04-17) 4 | 5 | * Upgrade to Dotty 0.22.0-RC1 6 | 7 | ## 0.5.0 (2020-02-06) 8 | 9 | * Add `.toContain` for `Map` objects 10 | See [toContain](docs/matchers.md#.toContain) 11 | 12 | * Add `.toContainAllOf` for `Map` objects 13 | See [toContain](docs/matchers.md#.toContainAllOf) 14 | 15 | * Upgrade to Dotty 0.22.0-RC1 16 | 17 | ## 0.4.0 (2019-12-21) 18 | 19 | * Upgrade to Dotty 0.21.0-RC1 20 | 21 | ## 0.3.0 (2019-11-03) 22 | 23 | * Upgrade to Dotty 0.20.0-RC1 24 | 25 | ## 0.2.0 (2019-11-03) 26 | 27 | * Make it possible to focus on a hierarchy / tree of tests using `focused` instead of `to` on context level. 28 | See [Focusing tests](docs/running-tests.md#Focusing-tests) for more details. 29 | 30 | * Make it possible to ignore on a hierarchy / tree of tests using `ignored` instead of `to` on context level. 31 | See [Focusing tests](docs/running-tests.md#Focusing-tests) for more details. 32 | 33 | * Upgrade to Dotty 0.19.0-RC1 34 | 35 | ## 0.1.0 (2019-10-17) 36 | 37 | * Initial public release 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.DS_Store 2 | *.class 3 | *.tasty 4 | *.hasTasty 5 | *.log 6 | *.swp 7 | *~ 8 | tags 9 | 10 | # sbt specific 11 | dist/* 12 | target/ 13 | lib_managed/ 14 | src_managed/ 15 | project/boot/ 16 | project/plugins/project/ 17 | project/local-plugins.sbt 18 | .history 19 | .ensime 20 | .ensime_cache/ 21 | .sbt-scripted/ 22 | local.sbt 23 | 24 | # npm 25 | node_modules 26 | 27 | # VS Code 28 | .vscode/ 29 | 30 | # Scala-IDE specific 31 | .scala_dependencies 32 | .cache 33 | .cache-main 34 | .cache-tests 35 | .classpath 36 | .project 37 | .settings 38 | classes/ 39 | */bin/ 40 | 41 | # Dotty IDE 42 | /.dotty-ide-dev-port 43 | /.dotty-ide-artifact 44 | /.dotty-ide.json 45 | 46 | # idea 47 | .idea 48 | .idea_modules 49 | /.worksheet/ 50 | 51 | # Partest 52 | dotty.jar 53 | dotty-lib.jar 54 | tests/partest-generated/ 55 | tests/locks/ 56 | /test-classes/ 57 | 58 | # Ignore output files but keep the directory 59 | out/ 60 | build/ 61 | !out/.keep 62 | testlogs/ 63 | _site/ 64 | 65 | # Ignore build-file 66 | .packages 67 | /.cache-main 68 | /.cache-tests 69 | 70 | # Put local stuff here 71 | local/ 72 | compiler/test/debug/Gen.jar 73 | 74 | *.dotty-ide-version 75 | 76 | # Vulpix output files 77 | *.check.out 78 | 79 | 80 | .bloop/ 81 | .metals/ 82 | project/metals.sbt -------------------------------------------------------------------------------- /src/test/scala/intent/formatters/FormatTest.scala: -------------------------------------------------------------------------------- 1 | package intent.formatters 2 | 3 | import intent._ 4 | import scala.util.{Success, Failure, Try} 5 | 6 | class FormatTest extends TestSuite with Stateless: 7 | "Formatting": 8 | "supports Long" in expect(format(42L)).toEqual("42") 9 | "surrounds Char in single quotes" in expect(format('a')).toEqual("'a'") 10 | "surrounds String in double quotes" in expect(format("a")).toEqual("\"a\"") 11 | "supports Option-Some" in expect(format(Some(42))).toEqual("Some(42)") 12 | "supports Option-None" in expect(format[Option[String]](None)).toEqual("None") 13 | "supports Try-Success" in expect(format(Success(42))).toEqual("Success(42)") 14 | "supports Try-Failure" in expect(format[Try[Int]](Failure(RuntimeException("oops")))).toEqual("Failure(java.lang.RuntimeException: oops)") 15 | "supports Try as Try" in: 16 | val t: Try[Int] = Success(42) 17 | expect(format(t)).toEqual("Success(42)") 18 | "supports recursive Option" in expect(format(Some("test"))).toEqual("Some(\"test\")") 19 | "supports Throwable" in: 20 | val t = RuntimeException("oops") 21 | expect(format(t)).toEqual("java.lang.RuntimeException: oops") 22 | 23 | def format[T](x: T)(using fmt: core.Formatter[T]): String = 24 | fmt.format(x) 25 | -------------------------------------------------------------------------------- /src/test/scala/intent/helpers/TestSuiteRunnerTester.scala: -------------------------------------------------------------------------------- 1 | package intent.helpers 2 | 3 | import scala.concurrent.{ExecutionContext, Future} 4 | import intent.core._ 5 | import intent.runner.{TestSuiteRunner, TestSuiteError, TestSuiteResult} 6 | 7 | /** 8 | * Supports running a test suite and checking the result. 9 | */ 10 | trait TestSuiteRunnerTester(using ec: ExecutionContext) extends Subscriber[TestCaseResult]: 11 | 12 | def suiteClassName: String 13 | 14 | private object lock 15 | val runner = new TestSuiteRunner(cl) 16 | var events = List[TestCaseResult]() 17 | 18 | def runAll(): Future[Either[TestSuiteError, TestSuiteResult]] = 19 | assert(suiteClassName != null, "Suite class name must be set") 20 | runner.runSuite(suiteClassName) 21 | 22 | def runWithEventSubscriber(): Future[Either[TestSuiteError, TestSuiteResult]] = 23 | assert(suiteClassName != null, "Suite class name must be set") 24 | runner.runSuite(suiteClassName, Some(this)) 25 | 26 | def evaluate(): IntentStructure = runner.evaluateSuite(suiteClassName).fold(_ => ???, identity) 27 | 28 | def receivedEvents(): Seq[TestCaseResult] = events 29 | 30 | override def onNext(event: TestCaseResult): Unit = 31 | lock.synchronized: 32 | events :+= event 33 | 34 | private def cl = getClass.getClassLoader 35 | -------------------------------------------------------------------------------- /site/themes/intent/layouts/partials/sidemenu.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 10 |
11 | 12 | 17 | 18 |
19 | {{ $currentPage := . }} 20 | {{ range .Site.Menus.main }} 21 | {{ if .HasChildren }} 22 |

{{ .Name }}

23 |
    24 | {{ range .Children }} 25 |
  • 26 | {{ .Name }} 27 |
  • 28 | {{ end }} 29 |
30 | {{ end }} 31 | {{ end }} 32 |
33 |
34 | -------------------------------------------------------------------------------- /src/test/scala/intent/docs/HowToAssert.scala: -------------------------------------------------------------------------------- 1 | package intent.docs 2 | 3 | import intent.{TestSuite, Stateless} 4 | 5 | // Example code used in documentation. 6 | // 7 | // These examples should **not** be used to test intent, only to illustrate 8 | // functions, patterns, etc. 9 | // 10 | // Unfortunately are these not referenced from any documentation tool, but have 11 | // to be copy pasted to documentation for now. 12 | 13 | class HowToAssert extends TestSuite with Stateless: 14 | "How to assert": 15 | "a boolean": 16 | "using toEqual" in: 17 | val coll = Seq.empty[Unit] 18 | expect(coll.isEmpty).toEqual(true) 19 | 20 | "when negated" in: 21 | val coll = Seq.empty[Unit] 22 | expect(coll.isEmpty).not.toEqual(false) 23 | 24 | "a number": 25 | "using toEqual" in: 26 | expect(42).toEqual(42) 27 | 28 | "using custom precision" in: 29 | given customPrecision as intent.core.FloatingPointPrecision[Double]: 30 | def numberOfDecimals: Int = 2 31 | expect(1.123456789d).toEqual(1.12d) 32 | 33 | "a sequence": 34 | "using a standard way" in: 35 | val coll = (1 to 3).toList 36 | expect(coll).toEqual(Seq(1, 2, 3)) 37 | 38 | "for a single element" in: 39 | val coll = (1 to 3).toList 40 | expect(coll).toContain(2) 41 | -------------------------------------------------------------------------------- /site/content/types-of-tests/table-driven.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Table-driven tests" 3 | date: 2020-04-09T13:44:39+02:00 4 | draft: false 5 | --- 6 | 7 | # Table-driven tests 8 | 9 | Table-driven tests allow for a declarative approach to writing tests, and is useful to 10 | test many different variations of some feature with as little boilerplate as possible. 11 | 12 | For example, consider the following test suite that tests a [Fibonacci](https://en.wikipedia.org/wiki/Fibonacci_number) function: 13 | 14 | ```scala 15 | class FibonacciTest extends TestSuite with State[TableState]: 16 | "The Fibonacci function" usingTable (examples) to : 17 | "works" in : 18 | example => 19 | expect(F(example.n)).toEqual(example.expected) 20 | 21 | def examples = Seq( 22 | FibonacciExample(0, 0), 23 | FibonacciExample(1, 1), 24 | FibonacciExample(2, 1), 25 | FibonacciExample(3, 2), 26 | FibonacciExample(12, 144) 27 | ) 28 | 29 | def F(n: Int): Int = ... // implemented elsewhere 30 | 31 | case class FibonacciExample(n: Int, expected: Int): 32 | override def toString = s"Fn($n) = $expected" 33 | ``` 34 | 35 | Intent will run the test code for each example returned by the `examples` 36 | method. This makes it easy to adjust examples and add new ones. 37 | 38 | > The exact syntax for table-driven tests may change in a future release. The 39 | current syntax results in test output that is a bit noisy. 40 | -------------------------------------------------------------------------------- /src/main/scala/intent/core/structure.scala: -------------------------------------------------------------------------------- 1 | package intent.core 2 | 3 | import scala.concurrent.duration._ 4 | import scala.concurrent.Future 5 | 6 | abstract class TestSuite {} 7 | 8 | trait Expectation: 9 | private[intent] def evaluate(): Future[ExpectationResult] 10 | 11 | sealed trait ExpectationResult 12 | case class TestPassed() extends ExpectationResult 13 | 14 | /** 15 | * TestFailed is used for errors happening once we have started to execute a test case. 16 | * This includes assertion errors/failures. 17 | * 18 | * @param output the failure output 19 | * @param ex optional exception, e.g. if some test state setup failed 20 | */ 21 | case class TestFailed(output: String, ex: Option[Throwable]) extends ExpectationResult 22 | 23 | /** 24 | * TestError is used for errors happening before we can start executing a test case. 25 | * 26 | * @param context information about the error 27 | * @param ex the optional exception behind the error 28 | */ 29 | case class TestError(context: String, ex: Option[Throwable]) extends ExpectationResult 30 | 31 | /** 32 | * TestIgnored is used to manually mark a test to not be evaluated. 33 | */ 34 | case class TestIgnored() extends ExpectationResult 35 | 36 | case class TestCaseResult(duration: FiniteDuration, qualifiedName: Seq[String], expectationResult: ExpectationResult) 37 | 38 | trait ITestCase: 39 | def nameParts: Seq[String] 40 | def run(): Future[TestCaseResult] 41 | -------------------------------------------------------------------------------- /docs/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | https://factor10.github.io/intent/types-of-tests/asynchronous/ 7 | 2020-04-09T13:44:39+02:00 8 | 9 | 10 | 11 | https://factor10.github.io/intent/customization/ 12 | 2020-04-09T13:44:39+02:00 13 | 14 | 15 | 16 | https://factor10.github.io/intent/ 17 | 2020-04-09T13:44:39+02:00 18 | 19 | 20 | 21 | https://factor10.github.io/intent/matchers/ 22 | 2020-04-09T13:44:39+02:00 23 | 24 | 25 | 26 | https://factor10.github.io/intent/types-of-tests/stateful/ 27 | 2020-04-09T13:44:39+02:00 28 | 29 | 30 | 31 | https://factor10.github.io/intent/types-of-tests/stateless/ 32 | 2020-04-09T13:44:39+02:00 33 | 34 | 35 | 36 | https://factor10.github.io/intent/types-of-tests/table-driven/ 37 | 2020-04-09T13:44:39+02:00 38 | 39 | 40 | 41 | https://factor10.github.io/intent/types-of-tests/ 42 | 2020-04-09T13:44:39+02:00 43 | 44 | 45 | 46 | https://factor10.github.io/intent/categories/ 47 | 48 | 49 | 50 | https://factor10.github.io/intent/tags/ 51 | 52 | 53 | -------------------------------------------------------------------------------- /src/test/scala/intent/styles/AsyncTest.scala: -------------------------------------------------------------------------------- 1 | package intent.styles 2 | 3 | import intent._ 4 | import helpers.Meta 5 | import scala.concurrent.{Future, ExecutionContext} 6 | 7 | class AsyncTest extends TestSuite with Stateless with Meta: 8 | 9 | "an async test": 10 | "can use whenComplete" in: 11 | val f = Future { 21 * 2 } 12 | whenComplete(f): 13 | result => expect(result).toEqual(42) 14 | 15 | "fails with failure in whenComplete" in: 16 | runExpectation({ 17 | val f = Future[Int] { throw new RuntimeException("async failure") } 18 | whenComplete(f): 19 | result => expect(result).toEqual(42) 20 | }, "The Future passed to 'whenComplete' failed") 21 | 22 | class AsyncStateTest extends TestSuite with AsyncState[MyAsyncState] with Meta: 23 | 24 | "a test with async state" usingAsync Future{MyAsyncState("Hello")} to: 25 | "can map on the state" using (_.map(" world")) to: 26 | "and sees the appropriate state" in: 27 | state => expect(state.s).toEqual("Hello world") 28 | 29 | "can async-map on the state" usingAsync (_.asyncMap(" async world")) to: 30 | "sees the appropriate state" in: 31 | state => expect(state.s).toEqual("Hello async world") 32 | 33 | "a test with initially sync state" using MyAsyncState("Hello") to: 34 | "can async-map on the state" usingAsync (_.asyncMap(" async world")) to: 35 | "sees the appropriate state" in: 36 | state => expect(state.s).toEqual("Hello async world") 37 | 38 | case class MyAsyncState(s: String)(using ExecutionContext): 39 | def map(s2: String) = copy(s = s + s2) 40 | def asyncMap(s2: String) = Future { copy(s = s + s2) } 41 | -------------------------------------------------------------------------------- /site/content/types-of-tests/asynchronous.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Asynchronous tests" 3 | date: 2020-04-09T13:44:39+02:00 4 | draft: false 5 | --- 6 | 7 | # Asynchronous tests 8 | 9 | Intent supports stateful tests where the state is produced asynchronously. An example: 10 | 11 | ```scala 12 | class AsyncStatefulTest extends TestSuite with AsyncState[AsyncStatefulState]: 13 | "an empty cart" using Cart() to : 14 | "with two items" usingAsync (_.add(CartItem("beach-chair", 2))) to : 15 | "and another three items" usingAsync (_.add(CartItem("sunscreen", 3))) to : 16 | "calculates total price" in : 17 | cart => expect(cart.totalPrice).toEqual(275.0d) 18 | 19 | case class CartItem(artNo: String, qty: Int) 20 | 21 | case class PricedCartItem(item: CartItem, price: Double): 22 | def totalPrice = item.qty * price 23 | 24 | case class Cart(items: Seq[PricedCartItem] = Seq.empty): 25 | def lookupPrice(artNo: String): Future[Double] = ... // e.g. using a test fake here 26 | 27 | def add(item: CartItem): Future[Cart] = 28 | lookupPrice(item.artNo).map: 29 | price => 30 | pricedItem = PricedCartItem(item, price) 31 | copy(items = items :+ pricedItem) 32 | 33 | def totalPrice = items.map(_.totalPrice).sum 34 | ``` 35 | 36 | Some notes here: 37 | * The initial state (`Cart()`) is not produced asynchronously (but could have been). 38 | * Asynchronous state production uses `usingAsync`. 39 | * The test itself is not asynchronous. 40 | 41 | The last point is worth expanding on. A test in an async-stateful test suite can be synchronous. 42 | Similarly, a test in a regular (non-async) stateful test suite can be asynchronous. Whether to choose 43 | `State` or `AsyncState` for a test suite depends on how the _state_ is produced. 44 | -------------------------------------------------------------------------------- /src/test/scala/intent/helpers/Meta.scala: -------------------------------------------------------------------------------- 1 | package intent.helpers 2 | 3 | import intent.core._ 4 | import scala.concurrent.{Future, ExecutionContext} 5 | import java.util.regex.Pattern 6 | 7 | trait Meta: 8 | self: TestLanguage with TestSupport => 9 | def runExpectation(e: => Expectation, expected: String)(using ExecutionContext): Expectation = 10 | new Expectation: 11 | def evaluate(): Future[ExpectationResult] = 12 | e.evaluate().flatMap: 13 | case TestFailed(s, _) => 14 | val expQ = Pattern.quote(expected) 15 | val fnQ = Pattern.quote(expectedFileName) 16 | expect(s).toMatch(s"^$expQ.*\\(.*$fnQ".r).evaluate() 17 | case TestPassed() => Future.successful(TestFailed("Expected a test failure", None)) 18 | case TestIgnored() => Future.successful(TestFailed("Expected a test failure", None)) 19 | case t: TestError => Future.successful(t) 20 | 21 | def runExpectation(e: => Expectation, exMatcher: PartialFunction[Throwable, Boolean])(using ExecutionContext): Expectation = 22 | new Expectation: 23 | def evaluate(): Future[ExpectationResult] = 24 | e.evaluate().flatMap: 25 | case TestFailed(_, Some(t)) => 26 | val isOk = exMatcher.applyOrElse(t, _ => false) 27 | val result = if isOk then TestPassed() else TestFailed("Unexpected exception", Some(t)) 28 | Future.successful(result) 29 | 30 | case TestFailed(s, _) => Future.successful(TestFailed(s"Expected a test exception, but got: $s", None)) 31 | case TestPassed() => Future.successful(TestFailed("Expected a test failure", None)) 32 | case TestIgnored() => Future.successful(TestFailed("Expected a test failure", None)) 33 | case t: TestError => Future.successful(t) 34 | 35 | private def expectedFileName = getClass.getSimpleName // Assume class X is in X.scala 36 | -------------------------------------------------------------------------------- /src/main/scala/intent/core/expectations/future.scala: -------------------------------------------------------------------------------- 1 | package intent.core.expectations 2 | 3 | import intent.core._ 4 | import intent.util.DelayedFuture 5 | import scala.util.matching.Regex 6 | import scala.concurrent.{ExecutionContext, Future} 7 | import scala.util.{Success, Failure} 8 | 9 | class ToCompleteWithExpectation[T](expect: Expect[Future[T]], expected: T)( 10 | using 11 | eqq: Eq[T], 12 | fmt: Formatter[T], 13 | errFmt: Formatter[Throwable], 14 | ec: ExecutionContext, 15 | timeout: TestTimeout) extends Expectation: 16 | 17 | def evaluate(): Future[ExpectationResult] = 18 | val timeoutFuture = DelayedFuture(timeout.timeout): 19 | throw TestTimeoutException() 20 | Future.firstCompletedOf(Seq(expect.evaluate(), timeoutFuture)).transform: 21 | case Success(actual) => 22 | var comparisonResult = eqq.areEqual(actual, expected) 23 | if expect.isNegated then comparisonResult = !comparisonResult 24 | val r = 25 | if !comparisonResult then 26 | val actualStr = fmt.format(actual) 27 | val expectedStr = fmt.format(expected) 28 | 29 | val desc = if expect.isNegated then 30 | s"Expected Future not to be completed with $expectedStr" 31 | else 32 | s"Expected Future to be completed with $expectedStr but found $actualStr" 33 | expect.fail(desc) 34 | else 35 | expect.pass 36 | 37 | Success(r) 38 | case Failure(t: TestTimeoutException) => 39 | Success(expect.fail("Test timed out")) 40 | case Failure(_) if expect.isNegated => 41 | // ok, Future was not completed with 42 | Success(expect.pass) 43 | case Failure(t) => 44 | val expectedStr = fmt.format(expected) 45 | val errorStr = errFmt.format(t) 46 | val desc = s"Expected Future to be completed with $expectedStr but it failed with $errorStr" 47 | val r = expect.fail(desc) 48 | Success(r) 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Intent 2 | 3 | [![Actions Status](https://github.com/factor10/intent/workflows/CI/badge.svg)](https://github.com/factor10/intent/actions) 4 | 5 | Intent is an opinionated test framework for [Dotty](https://dotty.epfl.ch). It builds on 6 | the following principles: 7 | 8 | * Low ceremony test code 9 | * Uniform test declaration 10 | * Futures and async testing 11 | * Arranging test state 12 | * Fast to run tests 13 | 14 | Here is an example on how the tests look: 15 | 16 | ```scala 17 | class StatefulTest extends TestSuite with State[Cart]: 18 | "an empty cart" using Cart() to : 19 | "with two items" using (_.add(CartItem("beach-chair", 2))) to : 20 | "and another three items" using (_.add(CartItem("sunscreen", 3))) to : 21 | "contains 5 items" in : 22 | cart => expect(cart.totalQuantity).toEqual(5) 23 | 24 | case class CartItem(artNo: String, qty: Int) 25 | 26 | case class Cart(items: Seq[CartItem] = Seq.empty): 27 | def add(item: CartItem): Cart = copy(items = items :+ item) 28 | def totalQuantity = items.map(_.qty).sum 29 | ``` 30 | 31 | This readme is focused on building and testing Intent, for documentation on 32 | how to use Intent to write tests, see [User documentation](https://factor10.github.io/intent/). 33 | 34 | ## Getting started 35 | 36 | Add Intent to your SBT project with the following lines to your `build.sbt`: 37 | 38 | ```scala 39 | libraryDependencies += "com.factor10" %% "intent" % "0.6.0", 40 | testFrameworks += new TestFramework("intent.sbt.Framework") 41 | ``` 42 | 43 | ## Development environment 44 | 45 | Intent is an early adopter of Dotty features, which means: 46 | 47 | * You need a recent Dotty (>= `0.25.0-RC1`) since Intent use the latest Scala 3 syntax. 48 | 49 | * Visual Studio Code seems to be the best supported editor (although not perfect) 50 | 51 | 52 | ## Contributing 53 | 54 | See [Contributing to Intent](./CONTRIBUTING.md) 55 | 56 | ## License 57 | 58 | Intent is Open Source and released under Apache 2.0. See `LICENSE` for details. 59 | -------------------------------------------------------------------------------- /site/content/types-of-tests/stateful.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Stateful tests" 3 | date: 2020-04-09T13:44:39+02:00 4 | draft: false 5 | --- 6 | 7 | # Stateful tests 8 | 9 | Not all tests can be implemented without setting the scene, still many test frameworks only focus 10 | on a expressive way to assert expectations. For `Intent` state management is front and center. 11 | 12 | Lets go straight to the code: 13 | 14 | ```scala 15 | class StatefulTest extends TestSuite with State[Cart]: 16 | "an empty cart" using Cart() to : 17 | "with two items" using (_.add(CartItem("beach-chair", 2))) to : 18 | "and another three items" using (_.add(CartItem("sunscreen", 3))) to : 19 | "contains 5 items" in : 20 | cart => expect(cart.totalQuantity).toEqual(5) 21 | 22 | case class CartItem(artNo: String, qty: Int) 23 | 24 | case class Cart(items: Seq[CartItem] = Seq.empty): 25 | def add(item: CartItem): Cart = copy(items = items :+ item) 26 | def totalQuantity = items.map(_.qty).sum 27 | ``` 28 | 29 | A test suite that needs state must implement `State[T]`, where `T` is the type carrying 30 | the state you need. There are _no requirements_ on the type `T` or its signature, you are free 31 | to use whatever type you want. We prefer to use `case class` as they are immutable, but any 32 | type will do. 33 | 34 | The _root state_ gets created in the `"root context"` and is passed downstream to any child context. 35 | Each child context has the possiblity to _transform_ the state before it is passed to the actual 36 | test as a parameter. 37 | 38 | ```scala 39 | "check the stuff" in : 40 | state => expect(state.stuff).toHaveLength(2) 41 | ``` 42 | 43 | A suite must _either_ be stateless or stateful. There is no support in writing a test that does not 44 | take a state when you derive from `State` and vice versa. While not the same, it resembles how Scala 45 | separate a `class` and an `object`. 46 | 47 | There are a few conventions or recommendations on how to use state: 48 | 49 | * Put the state implementation below the test 50 | * Prefer to call methods on the state object over doing it in the test itself 51 | * Keep state focused and create a new suite ands state class when needed (cost is low) 52 | -------------------------------------------------------------------------------- /macros/src/main/scala/intent/macros/source.scala: -------------------------------------------------------------------------------- 1 | package intent.macros 2 | 3 | /* 4 | * The Position macro is based on code in ScalaTest (https://github.com/scalatest/scalatest) 5 | * with the following license information: 6 | 7 | * Copyright 2001-2013 Artima, Inc. 8 | * 9 | * Licensed under the Apache License, Version 2.0 (the "License"); 10 | * you may not use this file except in compliance with the License. 11 | * You may obtain a copy of the License at 12 | * 13 | * http://www.apache.org/licenses/LICENSE-2.0 14 | * 15 | * Unless required by applicable law or agreed to in writing, software 16 | * distributed under the License is distributed on an "AS IS" BASIS, 17 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | * See the License for the specific language governing permissions and 19 | * limitations under the License. 20 | */ 21 | 22 | import scala.quoted._ 23 | 24 | /** 25 | * Represents a position in test code. Used to get the position of an 26 | * `expect` call. 27 | * 28 | * @param filePath the full path to the file that contains the `expect` 29 | * @param lineNumber0 zero-based line number of the `expect` call 30 | * @param columnNumber0 zero-based column number of the `expect` call 31 | */ 32 | case class Position(filePath: String, lineNumber0: Int, columnNumber0: Int) 33 | 34 | /** 35 | * Companion object for Position that defines an implicit 36 | * method that uses a macro to grab the enclosing position. 37 | */ 38 | object Position: 39 | 40 | /** 41 | * Implicit method, implemented with a macro, that returns the enclosing 42 | * source position where it is invoked. 43 | * 44 | * @return the enclosing source position 45 | */ 46 | implicit inline def here: Position = ${ genPosition } 47 | 48 | /** 49 | * Helper method for Position macro. 50 | */ 51 | private def genPosition(implicit qctx: QuoteContext): Expr[Position] = 52 | import qctx.tasty.rootPosition 53 | 54 | val file = rootPosition.sourceFile 55 | val filePath: String = file.toString 56 | val lineNo: Int = rootPosition.startLine 57 | val colNo: Int = rootPosition.startColumn 58 | '{ Position(${Expr(filePath)}, ${Expr(lineNo)}, ${Expr(colNo)}) } 59 | -------------------------------------------------------------------------------- /src/test/scala/intent/matchers/ToContainAllOfTest.scala: -------------------------------------------------------------------------------- 1 | package intent.matchers 2 | 3 | import intent.{Stateless, TestSuite} 4 | import intent.helpers.Meta 5 | 6 | class ToContainAllOfTest extends TestSuite with Stateless with Meta: 7 | "toContainAllOf": 8 | "for Map": 9 | "of String -> Int": 10 | "when negated": 11 | "error is described properly also for multiple elements" in: 12 | runExpectation( 13 | expect(Map("one" -> 1, "two" -> 2)).not.toContainAllOf("one" -> 1, "two" -> 2), 14 | """Expected Map(...) to not contain: 15 | | "one" -> 1 16 | | "two" -> 2""".stripMargin) 17 | 18 | "when partially matching": 19 | "error is described properly" in: 20 | runExpectation( 21 | expect(Map("one" -> 1, "two" -> 2)).toContainAllOf("one" -> 1, "three" -> 3), 22 | """Expected Map(...) to contain: 23 | | "three" -> 3""".stripMargin) 24 | "and negating": 25 | "should pass since Map is not missing both pairs" in: 26 | expect(Map("one" -> 1, "two" -> 2)).not.toContainAllOf("one" -> 1, "three" -> 3) 27 | 28 | "error is described properly" in: 29 | runExpectation( 30 | expect(Map("one" -> 11, "two" -> 22)).toContainAllOf("one" -> 1, "two" -> 2), 31 | """Expected Map(...) to contain: 32 | | "one" -> 1 but found "one" -> 11 33 | | "two" -> 2 but found "two" -> 22""".stripMargin) 34 | 35 | "with correct key and values": 36 | "should contain multiple elements" in: 37 | expect(Map("one" -> 1, "two" -> 2)).toContainAllOf("two" -> 2, "one" -> 1) 38 | 39 | "for scala.collection.mutable.Map": 40 | "with correct key and values": 41 | "should contain multiple elements" in: 42 | expect(scala.collection.mutable.Map("one" -> 1, "two" -> 2)).toContainAllOf("two" -> 2, "one" -> 1) 43 | 44 | "for scala.collection.immutable.Map": 45 | "with correct key and values": 46 | "should contain multiple elements" in: 47 | expect(scala.collection.immutable.Map("one" -> 1, "two" -> 2)).toContainAllOf("two" -> 2, "one" -> 1) 48 | -------------------------------------------------------------------------------- /old-docs/running-tests.md: -------------------------------------------------------------------------------- 1 | 2 | Tests are run by issuing the `sbt test` command. 3 | 4 | Each test will result in one of four states: 5 | 6 | * **Successful** - A test is successful if the expectation is successful, this is what it is all about. 7 | * **Failed** - A failed test is when the expectation was not fulfilled, or if an error occured during 8 | the execution of the test or the exeuction of the chained test-setup. 9 | * **Error** - Errors should be rare and typically occurs if a TestSuite could not be loaded or 10 | instantiated. 11 | * **Ignored** - Test was explicit set to not run. 12 | 13 | SBT prints a summary once all discovered tests are run 14 | 15 | ``` 16 | [info] Passed: Total 65, Failed 0, Errors 0, Passed 65, Ignored 1 17 | ``` 18 | 19 | 20 | ## Ignoring tests 21 | 22 | If you want to ignore a test you can substitute the `in` keyword with `ignore`. 23 | 24 | ```scala 25 | "ignored test" ignore: 26 | fail("This should never fail, only be ignored!") 27 | ``` 28 | 29 | If you want to ignore a hierarchy of tests, just substitute `to` with `ignored` 30 | 31 | E.g.: 32 | 33 | ```scala 34 | "name" using (state) ignored: 35 | "name" usingTable(source) ignored: 36 | "name" usingAsync(state) ignored: 37 | ``` 38 | 39 | Ignored tests will still be logged by SBT, but classified as `IGNORED` and printed 40 | in yellow. 41 | 42 | ``` 43 | [info] [IGNORED] intent.sbt.IgnoredTest >> ignored test (0 ms) 44 | ``` 45 | 46 | > Note: A ignored test will not be evaluated, but the signature must be valid so 47 | > it needs to return an expectation. 48 | 49 | 50 | ## Focusing tests 51 | 52 | If you only want to run a single (or a few) tests, you can substitute the `in` 53 | keyword with `focus`. 54 | 55 | ```scala 56 | "test work in progress" focus: 57 | expect(30 + 3).toEqual(33) 58 | ``` 59 | 60 | If you want to focus on a hierarchy of tests, just substitute `to` with `focused` 61 | 62 | E.g.: 63 | 64 | ```scala 65 | "name" using (state) focused: 66 | "name" usingTable(source) focused: 67 | "name" usingAsync(state) focused: 68 | ``` 69 | 70 | This will result in that _only_ focused tests are run, and all other tests are 71 | marked interpreted as ignored. 72 | 73 | > When in focued mode, SBT reporting will only print success or failure for tests 74 | > no ignored tests will be logged. 75 | -------------------------------------------------------------------------------- /src/main/scala/intent/core/formatters.scala: -------------------------------------------------------------------------------- 1 | package intent.core 2 | 3 | import scala.util.{Try, Success, Failure} 4 | import scala.reflect.ClassTag 5 | 6 | trait Formatter[T]: 7 | def format(value: T): String 8 | 9 | object IntFmt extends Formatter[Int]: 10 | def format(i: Int): String = i.toString 11 | 12 | object LongFmt extends Formatter[Long]: 13 | def format(l: Long): String = l.toString 14 | 15 | object DoubleFmt extends Formatter[Double]: 16 | def format(d: Double): String = d.toString // TODO: Consider Locale here, maybe take as given?? 17 | 18 | object FloatFmt extends Formatter[Float]: 19 | def format(f: Float): String = f.toString // TODO: Consider Locale here, maybe take as given?? 20 | 21 | object BooleanFmt extends Formatter[Boolean]: 22 | def format(b: Boolean): String = b.toString 23 | 24 | object StringFmt extends Formatter[String]: 25 | def format(str: String): String = s""""$str"""" 26 | 27 | object CharFmt extends Formatter[Char]: 28 | def format(ch: Char): String = s"'$ch'" 29 | 30 | class ThrowableFmt[T <: Throwable] extends Formatter[T]: 31 | def format(t: T): String = 32 | // TODO: stack trace?? 33 | s"${t.getClass.getName}: ${t.getMessage}" 34 | 35 | class OptionFmt[TInner, T <: Option[TInner]](using innerFmt: Formatter[TInner]) extends Formatter[T]: 36 | def format(value: T): String = value match 37 | case Some(x) => s"Some(${innerFmt.format(x)})" 38 | case None => "None" 39 | 40 | class TryFmt[TInner, T <: Try[TInner]](using innerFmt: Formatter[TInner], throwableFmt: Formatter[Throwable]) extends Formatter[T]: 41 | def format(value: T): String = value match 42 | case Success(x) => s"Success(${innerFmt.format(x)})" 43 | case Failure(t) => s"Failure(${throwableFmt.format(t)})" 44 | 45 | trait FormatterGivens: 46 | given Formatter[Int] = IntFmt 47 | given Formatter[Long] = LongFmt 48 | given [T <: Throwable] as Formatter[T] = ThrowableFmt[T] 49 | given Formatter[Boolean] = BooleanFmt 50 | given Formatter[String] = StringFmt 51 | given Formatter[Char] = CharFmt 52 | given Formatter[Double] = DoubleFmt 53 | given Formatter[Float] = FloatFmt 54 | 55 | given optFmt[TInner, T <: Option[TInner]](using Formatter[TInner]) as Formatter[T] = OptionFmt[TInner, T] 56 | given tryFmt[TInner, T <: Try[TInner]](using Formatter[TInner]) as Formatter[T] = TryFmt[TInner, T] 57 | -------------------------------------------------------------------------------- /src/main/scala/intent/util/futures.scala: -------------------------------------------------------------------------------- 1 | package intent.util 2 | 3 | import java.util.concurrent.atomic.AtomicBoolean 4 | import java.util.{Timer, TimerTask} 5 | 6 | import scala.concurrent.duration.Duration 7 | import scala.concurrent._ 8 | import scala.util.Try 9 | 10 | trait DelayedFuture[T] extends Future[T]: 11 | def cancel(): Unit 12 | 13 | // From: http://stackoverflow.com/questions/16359849/scala-scheduledfuture 14 | object DelayedFuture: 15 | private val timer = new Timer 16 | 17 | private def makeTask[T](body: => T)(schedule: TimerTask => Unit)(using ctx: ExecutionContext): Future[T] = 18 | val prom = Promise[T]() 19 | schedule( 20 | new TimerTask { 21 | override def run() = 22 | // IMPORTANT: The timer task just starts the execution on the passed 23 | // ExecutionContext and is thus almost instantaneous (making it 24 | // practical to use a single Timer - hence a single background thread). 25 | ctx.execute(() => prom.complete(Try(body))) 26 | } 27 | ) 28 | prom.future 29 | 30 | def apply[T](duration: Duration)(body: => T)(using ctx: ExecutionContext): DelayedFuture[T] = 31 | val isCancelled = new AtomicBoolean(false) 32 | val f = makeTask({ 33 | if (!isCancelled.get()) body else null.asInstanceOf[T] 34 | })(timer.schedule(_, duration.toMillis)) 35 | DelayedFutureImpl(f, () => isCancelled.set(true)) 36 | 37 | private class DelayedFutureImpl[T](val inner: Future[T], cancelFun: () => Unit) extends DelayedFuture[T]: 38 | override def cancel(): Unit = cancelFun() 39 | 40 | override def onComplete[U](f: (Try[T]) => U)(implicit executor: ExecutionContext): Unit = inner.onComplete(f) 41 | 42 | override def isCompleted: Boolean = inner.isCompleted 43 | 44 | override def value: Option[Try[T]] = inner.value 45 | 46 | override def ready(atMost: Duration)(implicit permit: CanAwait): DelayedFutureImpl.this.type = 47 | inner.ready(atMost) 48 | this 49 | 50 | override def result(atMost: Duration)(implicit permit: CanAwait): T = inner.result(atMost) 51 | 52 | override def transform[S](f: Try[T] => Try[S])(implicit executor: ExecutionContext): Future[S] = 53 | inner.transform(f) 54 | 55 | override def transformWith[S](f: Try[T] => Future[S])(implicit executor: ExecutionContext): Future[S] = 56 | inner.transformWith(f) 57 | -------------------------------------------------------------------------------- /site/content/customization.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Customization" 3 | date: 2020-04-09T13:44:39+02:00 4 | draft: false 5 | --- 6 | ## Manually fail or succeed a test 7 | 8 | Two convenience methods exists where you can manually provide the the test expectation: 9 | 10 | * `fail("Reason for failure...")` to fail a test 11 | * `success()` to pass a test 12 | 13 | ## Asyncness 14 | 15 | If you need to await the result of a `Future` before using a matcher, you can use 16 | `whenComplete`: 17 | 18 | ```scala 19 | whenComplete(Future.successful(Seq("foo", "bar"))): 20 | actual => expect(actual).toContain("foo") 21 | ``` 22 | 23 | This allows for more complex testing compared to when using `toCompleteWith`. 24 | 25 | > `whenComplete` can be used regardless of the suite type, i.e. it doesn't need to 26 | be in an `AsyncState` suite. The async part of `AsyncState` allows for building the 27 | test state asynchronously, but has nothing to do with the expectations used. 28 | 29 | ## Equality 30 | 31 | It is possible to define custom equality for a type. Consider the following example 32 | from Intent's own test suite: 33 | 34 | ```scala 35 | given customIntEq as intent.core.Eq[Int] : 36 | def areEqual(a: Int, b: Int) = Math.abs(a - b) == 1 37 | expect(Some(42)).toEqual(Some(43)) 38 | ``` 39 | 40 | In this case, a custom equality definition for `Int` says that two values 41 | are equal if they diff by 1. This causes the `toEqual` matcher to succeed. 42 | 43 | ## Floating-point precision 44 | 45 | When floating-point values (`Float` and `Double`) are compared, Intent compares up 46 | to a certain precision, defined as the number of decimals that must match. 47 | 48 | Here's an example where a custom precision is used: 49 | 50 | ```scala 51 | given customPrecision as intent.core.FloatingPointPrecision[Float] : 52 | def numberOfDecimals: Int = 2 53 | expect(1.234f).toEqual(1.235f) 54 | ``` 55 | 56 | The test passes because we say that two `Float`s are equal to the precision of 57 | 2 decimals. In other words, the equality check actually compares 1.23 and 1.23. 58 | 59 | The default precision is 12 decimals for `Double` and 6 decimals for `Float`. 60 | 61 | ## Formatting 62 | 63 | It is possible to customize how a value is printed in a test failure message. 64 | Here's an example from Intent's test suite that shows how: 65 | 66 | ```scala 67 | given customIntFmt as core.Formatter[Int] : 68 | def format(a: Int): String = a.toString.replace("4", "forty-") 69 | runExpectation(expect(42).toEqual(43), 70 | "Expected forty-3 but found forty-2") 71 | ``` 72 | 73 | ## Timeout 74 | 75 | The default timeout for `whenComplete` and `toCompleteWith` is 5 seconds. 76 | It is possible to use a custom timeout: 77 | 78 | ```scala 79 | given customTimeout as TestTimeout = TestTimeout(500.millis) 80 | expect(someFuture).toCompleteWith("fast") 81 | ``` 82 | -------------------------------------------------------------------------------- /src/main/scala/intent/core/expectations/throw.scala: -------------------------------------------------------------------------------- 1 | package intent.core.expectations 2 | 3 | import intent.core._ 4 | import scala.concurrent.Future 5 | import scala.reflect.ClassTag 6 | import scala.util.{Try, Success, Failure} 7 | import scala.util.matching.Regex 8 | 9 | sealed trait ExpectedMessage: 10 | def matches(msg: String): Boolean 11 | def describe(): String 12 | 13 | object AnyExpectedMessage extends ExpectedMessage: 14 | def matches(msg: String) = true 15 | def describe() = "" 16 | 17 | class ExactExpectedMessage(expected: String)(using stringFmt: Formatter[String]) extends ExpectedMessage: 18 | def matches(msg: String) = msg == expected 19 | def describe() = s" with message ${stringFmt.format(expected)}" 20 | 21 | class RegexExpectedMessage(re: Regex) extends ExpectedMessage: 22 | def matches(msg: String) = re.findFirstMatchIn(msg).isDefined 23 | def describe() = s" with message matching /${re}/" 24 | 25 | private case class ExceptionMatch(typeOk: Boolean, messageOk: Boolean): 26 | def successful(isNegated: Boolean) = 27 | val ok = typeOk && messageOk 28 | if isNegated then !ok else ok 29 | 30 | class ThrowExpectation[TEx : ClassTag](expect: Expect[_], expectedMessage: ExpectedMessage)(using stringFmt: Formatter[String]) extends Expectation: 31 | def evaluate(): Future[ExpectationResult] = 32 | val expectedClass = implicitly[ClassTag[TEx]].runtimeClass 33 | 34 | def messageMatches(t: Throwable) = expectedMessage.matches(t.getMessage) 35 | def hasRightType(t: Throwable) = expectedClass.isAssignableFrom(t.getClass) 36 | def matchOn(t: Throwable): ExceptionMatch = ExceptionMatch(hasRightType(t), messageMatches(t)) 37 | 38 | val r = Try(expect.evaluate()) match 39 | case Success(_) if expect.isNegated => 40 | expect.pass 41 | case Success(_) => 42 | val msg = s"Expected the code to throw ${expectedClass.getName}, but it did not throw anything" 43 | expect.fail(msg) 44 | case Failure(t) => 45 | val exceptionMatch = matchOn(t) 46 | if exceptionMatch.successful(expect.isNegated) then 47 | expect.pass 48 | else if expect.isNegated then 49 | val details = if t.getClass == expectedClass then "did" else s"threw ${t.getClass.getName}" 50 | val msg = s"Expected the code not to throw ${expectedClass.getName}${expectedMessage.describe()}, but it ${details}" 51 | expect.fail(msg) 52 | else 53 | val msg = if exceptionMatch.typeOk then 54 | s"Expected the code to throw ${expectedClass.getName}${expectedMessage.describe()}, but the message was ${stringFmt.format(t.getMessage)}" 55 | else 56 | s"Expected the code to throw ${expectedClass.getName}, but it threw ${t.getClass.getName}" 57 | expect.fail(msg, t) 58 | 59 | Future.successful(r) 60 | -------------------------------------------------------------------------------- /old-docs/how-to-assert.md: -------------------------------------------------------------------------------- 1 | Here are some guidance on how to assert when you have... 2 | 3 | ## A boolean 4 | 5 | The simplest use-case is when you just need to assert if a value is `true` or `false`. 6 | 7 | ```scala 8 | "using toEqual" in: 9 | val coll = Seq.empty[Unit] 10 | expect(coll.isEmpty).toEqual(true) 11 | ``` 12 | 13 | Boolean matcher can also be negated: 14 | 15 | ```scala 16 | "when negated" in: 17 | val coll = Seq.empty[Unit] 18 | expect(coll.isEmpty).not.toEqual(false) 19 | ``` 20 | 21 | **TIP:** 22 | > If possible, avoid evaluating an expression to a Boolean value, since it makes the assertion 23 | > error contain less information upon failure (e.g. expected true to be false): 24 | > ``` 25 | > [info] [FAILED] intent.docs.HowToAssert >> How to >> assert a boolean condition (8 ms) 26 | > [info] Expected false but found true (intent/src/test/scala/intent/docs/HowToAssert.scala:17:27) 27 | > ``` 28 | 29 | For this specific example, matching on a empty sequence would have rendered far more detail 30 | in the case of assertion error (e.g. `expect(col).toEqual(Seq.empty)`). 31 | 32 | 33 | ## A number 34 | 35 | Numbers (Int, Long, Double, Float) can all be match using `isEqual`: 36 | 37 | ```scala 38 | "using toEqual" in: 39 | expect(42).toEqual(42) 40 | ``` 41 | 42 | When it comes to decimal numbers, you most likely want to limit the precision to _N_ number of decimals. 43 | 44 | This is achieved by providing a implicit `FloatingPointPrecision` implementation. Since it is an implicit 45 | it is up to you if you want to use a suite wide implementation or inline one in the test. In the example below 46 | we inline the precision to two digits: 47 | 48 | ```scala 49 | "using custom precision" in: 50 | given customPrecision: intent.core.FloatingPointPrecision[Double] 51 | def numberOfDecimals: Int = 2 52 | expect(1.123456789d).toEqual(1.12d) 53 | ``` 54 | 55 | 56 | ## A sequence 57 | 58 | Asserting some condition for a sequence is a very common scenario. 59 | 60 | When the collection is small, the expected value can be expressed inline such as: 61 | 62 | ```scala 63 | val coll = (1 to 3).toList 64 | expect(coll).toEqual(Seq(1, 2, 3)) 65 | ``` 66 | 67 | If only a single element is of interest, there is a `.toContain` matcher that can be used. Upon failure 68 | the list will be printed together with the failing item. 69 | 70 | ```scala 71 | val coll = (1 to 3).toList 72 | expect(coll).toContain(4) 73 | 74 | [info] [FAILED] intent.docs.HowToAssert >> How to assert >> a sequence >> for a single element (8 ms) 75 | [info] Expected List(1, 2, 3) to contain 4 (intent/src/test/scala/intent/docs/HowToAssert.scala:31:21) 76 | ``` 77 | 78 | **TIP:** 79 | * Testing length may be brittle if the producer is modified to create more items - testing that the list 80 | contains some and not others (two tests) is likely more stable. 81 | 82 | 83 | ## General recommendations 84 | 85 | Regardless of data-type or matcher, there are a few principles that are valid for most tests: 86 | 87 | * Always make the test fail to make sure you are actually testing something. 88 | * Think about the failure, does a failing test give enough details? 89 | -------------------------------------------------------------------------------- /src/test/scala/intent/util/DelayedFutureTest.scala: -------------------------------------------------------------------------------- 1 | package intent.util 2 | 3 | import intent._ 4 | import intent.core.Expectation 5 | 6 | import java.util.concurrent.Executors 7 | 8 | import scala.concurrent.{Await, ExecutionContext, Promise} 9 | import scala.concurrent.duration._ 10 | import scala.language.postfixOps 11 | import scala.util.{Success, Try} 12 | 13 | class DelayedFutureTestState: 14 | val executorService = Executors.newFixedThreadPool(1) 15 | given executionContext as ExecutionContext = ExecutionContext.fromExecutorService(executorService) 16 | 17 | val executorThreadId = 18 | // Determine the thread ID of the execution context, so we can compare with that in the tests. 19 | val p = Promise[Long]() 20 | executionContext.execute(() => p.success(Thread.currentThread().getId)) 21 | Await.result(p.future, 2 seconds) 22 | 23 | 24 | object DelayedFutureTestState: 25 | val instance = DelayedFutureTestState() 26 | 27 | class DelayedFutureTest extends TestSuite with State[DelayedFutureTestState]: 28 | 29 | "A DelayedFuture" using (DelayedFutureTestState.instance) to: 30 | 31 | "should run the callback in the supplied execution context" in: 32 | s => 33 | given executionContext as ExecutionContext = ExecutionContext.fromExecutorService(s.executorService) 34 | 35 | val f = DelayedFuture(10 milliseconds)(Thread.currentThread().getId) 36 | whenComplete(f): 37 | tid => expect(tid).toEqual(s.executorThreadId) 38 | 39 | "should be cancellable" in: 40 | s => 41 | given executionContext as ExecutionContext = ExecutionContext.fromExecutorService(s.executorService) 42 | 43 | var hasRun = false 44 | val f = DelayedFuture(100 milliseconds): 45 | hasRun = true 46 | f.cancel() 47 | whenComplete(f): 48 | _ => expect(hasRun).toEqual(false) 49 | 50 | "should have a default result after being cancelled" in: 51 | s => 52 | given executionContext as ExecutionContext = ExecutionContext.fromExecutorService(s.executorService) 53 | 54 | val f = DelayedFuture(100 milliseconds) { 42 } 55 | f.cancel() 56 | whenComplete(f): 57 | value => expect(value).toEqual(0) 58 | 59 | "should be chainable" in: 60 | s => 61 | given executionContext as ExecutionContext = ExecutionContext.fromExecutorService(s.executorService) 62 | 63 | val f = DelayedFuture(10 milliseconds) { 21 }.map(_ * 2) 64 | whenComplete(f): 65 | result => expect(result).toEqual(42) 66 | 67 | "should be awaitable with result" in: 68 | s => 69 | given executionContext as ExecutionContext = ExecutionContext.fromExecutorService(s.executorService) 70 | 71 | val f = DelayedFuture(10 milliseconds) { 42 } 72 | val result = Await.result(f, 100 milliseconds) 73 | expect(result).toEqual(42) 74 | 75 | "should be awaitable without result" in: 76 | s => 77 | given executionContext as ExecutionContext = ExecutionContext.fromExecutorService(s.executorService) 78 | 79 | val f = DelayedFuture(10 milliseconds) { 42 } 80 | val f2 = Await.ready(f, 100 milliseconds) 81 | expect[Option[Try[Int]]](f2.value).toEqual(Some(Success(42))) 82 | -------------------------------------------------------------------------------- /src/test/scala/intent/matchers/ToContainTest.scala: -------------------------------------------------------------------------------- 1 | package intent.matchers 2 | 3 | import intent.{Stateless, TestSuite} 4 | import intent.helpers.Meta 5 | 6 | class ToContainTest extends TestSuite with Stateless with Meta: 7 | "toContain": 8 | "a list of Int": 9 | "should contain element" in expect(Seq(1, 2, 3)).toContain(2) 10 | "should **not** contain missing element" in expect(Seq(1, 2, 3)).not.toContain(4) 11 | 12 | "a list of String": 13 | "should contain element" in expect(Seq("one", "two", "three")).toContain("two") 14 | "should **not** contain missing element" in expect(Seq("one", "two", "three")).not.toContain("four") 15 | 16 | "a list of Boolean": 17 | "should contain element" in expect(Seq(true, false)).toContain(false) 18 | "should **not** contain missing element" in expect(Seq(true)).not.toContain(false) 19 | 20 | "a null list": 21 | "should not contain an element" in expect(null.asInstanceOf[Seq[Int]]).not.toContain(42) 22 | 23 | "is described properly when actual is null" in: 24 | runExpectation(expect(null.asInstanceOf[Seq[Int]]).toContain(42), 25 | "Expected to contain 42") 26 | 27 | "an infinite list": 28 | "can be contains-checked, but will abort" in: 29 | val list = LazyList.from(1) 30 | expect(list).not.toContain(-1) 31 | 32 | "can be contains-checked, but will not detect anything beyond the limit" in: 33 | given cutoff as intent.core.ListCutoff = intent.core.ListCutoff(5) 34 | val list = LazyList.from(1) 35 | expect(list).not.toContain(10) 36 | 37 | "for Map": 38 | "of String -> Int": 39 | "when negated": 40 | "does not contain element" in: 41 | expect(Map("one" -> 1, "two" -> 2)).not.toContain("three" -> 3) 42 | 43 | "error is described properly" in: 44 | runExpectation( 45 | expect(Map("one" -> 1, "two" -> 2)).not.toContain("one" -> 1), 46 | """Expected Map(...) to not contain: 47 | | "one" -> 1""".stripMargin) 48 | 49 | "with invalid key": 50 | "error is described properly" in: 51 | runExpectation( 52 | expect(Map("one" -> 1, "two" -> 2)).toContain("three" -> 3), 53 | """Expected Map(...) to contain: 54 | | "three" -> 3""".stripMargin) 55 | 56 | "with correct key but invalid value": 57 | "error is described properly" in: 58 | runExpectation( 59 | expect(Map("one" -> 1, "two" -> 2)).toContain("two" -> 3), 60 | """Expected Map(...) to contain: 61 | | "two" -> 3 but found "two" -> 2""".stripMargin) 62 | 63 | "with correct key and value": 64 | "should contain element" in: 65 | expect(Map("one" -> 1, "two" -> 2)).toContain("two" -> 2) 66 | 67 | "for mutable scala.collection.mutable.Map": 68 | "with correct key and value": 69 | "should contain element" in: 70 | expect(scala.collection.mutable.Map("one" -> 1, "two" -> 2)).toContain("two" -> 2) 71 | 72 | "for scala.collection.immutable.Map": 73 | "with correct key and value": 74 | "should contain element" in: 75 | expect(scala.collection.immutable.Map("one" -> 1, "two" -> 2)).toContain("two" -> 2) 76 | -------------------------------------------------------------------------------- /src/main/scala/intent/core/equality.scala: -------------------------------------------------------------------------------- 1 | package intent.core 2 | 3 | import scala.reflect.ClassTag 4 | import scala.util.{Try, Success, Failure} 5 | 6 | trait Eq[T]: 7 | def areEqual(a: T, b: T): Boolean 8 | 9 | trait FloatingPointPrecision[T]: 10 | def numberOfDecimals: Int 11 | 12 | object DefaultDoubleFloatingPointPrecision extends FloatingPointPrecision[Double]: 13 | def numberOfDecimals: Int = 12 14 | 15 | object DefaultFloatFloatingPointPrecision extends FloatingPointPrecision[Float]: 16 | def numberOfDecimals: Int = 6 17 | 18 | private def compareFPs[T : Numeric](a: T, b: T)(using prec: FloatingPointPrecision[T]): Boolean = 19 | if a == b then 20 | return true 21 | val num = summon[Numeric[T]] 22 | val mul = math.pow(10, prec.numberOfDecimals.asInstanceOf[Double]) 23 | val am = math.floor(num.toDouble(a) * mul) 24 | val bm = math.floor(num.toDouble(b) * mul) 25 | am == bm 26 | 27 | object IntEq extends Eq[Int]: 28 | def areEqual(a: Int, b: Int): Boolean = a == b 29 | 30 | object LongEq extends Eq[Long]: 31 | def areEqual(a: Long, b: Long): Boolean = a == b 32 | 33 | class DoubleEq(using prec: FloatingPointPrecision[Double]) extends Eq[Double]: 34 | def areEqual(a: Double, b: Double): Boolean = compareFPs(a, b) 35 | 36 | class FloatEq(using prec: FloatingPointPrecision[Float]) extends Eq[Float]: 37 | def areEqual(a: Float, b: Float): Boolean = compareFPs(a, b) 38 | 39 | object BooleanEq extends Eq[Boolean]: 40 | def areEqual(a: Boolean, b: Boolean): Boolean = a == b 41 | 42 | object StringEq extends Eq[String]: 43 | def areEqual(a: String, b: String): Boolean = a == b 44 | 45 | object CharEq extends Eq[Char]: 46 | def areEqual(a: Char, b: Char): Boolean = a == b 47 | 48 | class ThrowableEq[T <: Throwable] extends Eq[T]: 49 | def areEqual(a: T, b: T): Boolean = a == b 50 | 51 | object AnyEq extends Eq[Any]: 52 | def areEqual(a: Any, b: Any): Boolean = a == b 53 | 54 | object NothingEq extends Eq[Nothing]: 55 | def areEqual(a: Nothing, b: Nothing): Boolean = a == b 56 | 57 | class OptionEq[TInner, T <: Option[TInner]](using innerEq: Eq[TInner]) extends Eq[T]: 58 | def areEqual(a: T, b: T): Boolean = 59 | (a, b) match 60 | case (Some(aa), Some(bb)) => innerEq.areEqual(aa, bb) 61 | case (None, None) => true 62 | case _ => false 63 | 64 | class TryEq[TInner, T <: Try[TInner]](using innerEq: Eq[TInner], throwableEq: Eq[Throwable]) extends Eq[T]: 65 | def areEqual(a: T, b: T): Boolean = 66 | (a, b) match 67 | case (Success(aa), Success(bb)) => innerEq.areEqual(aa, bb) 68 | case (Failure(ta), Failure(tb)) => throwableEq.areEqual(ta, tb) 69 | case _ => false 70 | 71 | trait EqGivens: 72 | 73 | given FloatingPointPrecision[Double] = DefaultDoubleFloatingPointPrecision 74 | given FloatingPointPrecision[Float] = DefaultFloatFloatingPointPrecision 75 | 76 | given Eq[Int] = IntEq 77 | given Eq[Long] = LongEq 78 | given Eq[Boolean] = BooleanEq 79 | given Eq[String] = StringEq 80 | given Eq[Char] = CharEq 81 | given (using FloatingPointPrecision[Double]) as Eq[Double] = DoubleEq() 82 | given (using FloatingPointPrecision[Float]) as Eq[Float] = FloatEq() 83 | given throwableEq[T <: Throwable] as Eq[T] = ThrowableEq[T] 84 | given optEq[TInner, T <: Option[TInner]](using Eq[TInner]) as Eq[T] = OptionEq[TInner, T] 85 | given tryEq[TInner, T <: Try[TInner]](using Eq[TInner]) as Eq[T] = TryEq[TInner, T] 86 | -------------------------------------------------------------------------------- /docs/prism.intent.css: -------------------------------------------------------------------------------- 1 | /* PrismJS 1.19.0 2 | https://prismjs.com/download.html#themes=prism-tomorrow&languages=clike+java+scala&plugins=line-numbers */ 3 | 4 | /* 5 | * This theme is based on the Prism tomorrow-theme but adopted for Intent. 6 | * 7 | * Based on https://github.com/chriskempson/tomorrow-theme originally by Rose Pritchard 8 | */ 9 | 10 | code[class*="language-"], 11 | pre[class*="language-"] { 12 | color: #252525; 13 | background: none; 14 | font-family: monospace; 15 | font-size: 1.1em; 16 | text-align: left; 17 | white-space: pre; 18 | word-spacing: normal; 19 | word-break: normal; 20 | word-wrap: normal; 21 | line-height: 1.5; 22 | 23 | -moz-tab-size: 4; 24 | -o-tab-size: 4; 25 | tab-size: 4; 26 | 27 | -webkit-hyphens: none; 28 | -moz-hyphens: none; 29 | -ms-hyphens: none; 30 | hyphens: none; 31 | } 32 | 33 | /* Code blocks */ 34 | pre[class*="language-"] { 35 | padding: 0; 36 | margin: 0; 37 | overflow: auto; 38 | } 39 | 40 | /* Inline code */ 41 | :not(pre) > code[class*="language-"] { 42 | padding: .1em; 43 | border-radius: .3em; 44 | white-space: normal; 45 | } 46 | 47 | .token.comment, 48 | .token.block-comment, 49 | .token.prolog, 50 | .token.doctype, 51 | .token.cdata { 52 | color: #999; 53 | } 54 | 55 | .token.punctuation { 56 | color: #252525; 57 | } 58 | 59 | .token.tag, 60 | .token.attr-name, 61 | .token.namespace, 62 | .token.deleted { 63 | color: #e2777a; 64 | } 65 | 66 | .token.function-name { 67 | color: #6196cc; 68 | } 69 | 70 | .token.boolean, 71 | .token.number, 72 | .token.function { 73 | color: #eb7d34; 74 | } 75 | 76 | .token.property, 77 | .token.class-name, 78 | .token.constant, 79 | .token.symbol { 80 | color: #f8c555; 81 | } 82 | 83 | .token.selector, 84 | .token.important, 85 | .token.atrule, 86 | .token.keyword, 87 | .token.builtin { 88 | color: #7b70b0; 89 | } 90 | 91 | .token.string, 92 | .token.char, 93 | .token.attr-value, 94 | .token.regex, 95 | .token.variable { 96 | color: #5B9940; 97 | } 98 | 99 | .token.operator, 100 | .token.entity, 101 | .token.url { 102 | color: #3198b8; 103 | } 104 | 105 | .token.important, 106 | .token.bold { 107 | font-weight: bold; 108 | } 109 | .token.italic { 110 | font-style: italic; 111 | } 112 | 113 | .token.entity { 114 | cursor: help; 115 | } 116 | 117 | .token.inserted { 118 | color: green; 119 | } 120 | 121 | pre[class*="language-"].line-numbers { 122 | position: relative; 123 | padding-left: 3.8em; 124 | counter-reset: linenumber; 125 | } 126 | 127 | pre[class*="language-"].line-numbers > code { 128 | position: relative; 129 | white-space: inherit; 130 | } 131 | 132 | .line-numbers .line-numbers-rows { 133 | position: absolute; 134 | pointer-events: none; 135 | top: 0; 136 | font-size: 100%; 137 | left: -3.8em; 138 | width: 3em; /* works for line-numbers below 1000 lines */ 139 | letter-spacing: -1px; 140 | border-right: 1px solid #999; 141 | 142 | -webkit-user-select: none; 143 | -moz-user-select: none; 144 | -ms-user-select: none; 145 | user-select: none; 146 | } 147 | 148 | .line-numbers-rows > span { 149 | pointer-events: none; 150 | display: block; 151 | counter-increment: linenumber; 152 | } 153 | 154 | .line-numbers-rows > span:before { 155 | content: counter(linenumber); 156 | color: #999; 157 | display: block; 158 | padding-right: 0.8em; 159 | text-align: right; 160 | } 161 | -------------------------------------------------------------------------------- /site/themes/intent/static/prism.intent.css: -------------------------------------------------------------------------------- 1 | /* PrismJS 1.19.0 2 | https://prismjs.com/download.html#themes=prism-tomorrow&languages=clike+java+scala&plugins=line-numbers */ 3 | 4 | /* 5 | * This theme is based on the Prism tomorrow-theme but adopted for Intent. 6 | * 7 | * Based on https://github.com/chriskempson/tomorrow-theme originally by Rose Pritchard 8 | */ 9 | 10 | code[class*="language-"], 11 | pre[class*="language-"] { 12 | color: #252525; 13 | background: none; 14 | font-family: monospace; 15 | font-size: 1.1em; 16 | text-align: left; 17 | white-space: pre; 18 | word-spacing: normal; 19 | word-break: normal; 20 | word-wrap: normal; 21 | line-height: 1.5; 22 | 23 | -moz-tab-size: 4; 24 | -o-tab-size: 4; 25 | tab-size: 4; 26 | 27 | -webkit-hyphens: none; 28 | -moz-hyphens: none; 29 | -ms-hyphens: none; 30 | hyphens: none; 31 | } 32 | 33 | /* Code blocks */ 34 | pre[class*="language-"] { 35 | padding: 0; 36 | margin: 0; 37 | overflow: auto; 38 | } 39 | 40 | /* Inline code */ 41 | :not(pre) > code[class*="language-"] { 42 | padding: .1em; 43 | border-radius: .3em; 44 | white-space: normal; 45 | } 46 | 47 | .token.comment, 48 | .token.block-comment, 49 | .token.prolog, 50 | .token.doctype, 51 | .token.cdata { 52 | color: #999; 53 | } 54 | 55 | .token.punctuation { 56 | color: #252525; 57 | } 58 | 59 | .token.tag, 60 | .token.attr-name, 61 | .token.namespace, 62 | .token.deleted { 63 | color: #e2777a; 64 | } 65 | 66 | .token.function-name { 67 | color: #6196cc; 68 | } 69 | 70 | .token.boolean, 71 | .token.number, 72 | .token.function { 73 | color: #eb7d34; 74 | } 75 | 76 | .token.property, 77 | .token.class-name, 78 | .token.constant, 79 | .token.symbol { 80 | color: #f8c555; 81 | } 82 | 83 | .token.selector, 84 | .token.important, 85 | .token.atrule, 86 | .token.keyword, 87 | .token.builtin { 88 | color: #7b70b0; 89 | } 90 | 91 | .token.string, 92 | .token.char, 93 | .token.attr-value, 94 | .token.regex, 95 | .token.variable { 96 | color: #5B9940; 97 | } 98 | 99 | .token.operator, 100 | .token.entity, 101 | .token.url { 102 | color: #3198b8; 103 | } 104 | 105 | .token.important, 106 | .token.bold { 107 | font-weight: bold; 108 | } 109 | .token.italic { 110 | font-style: italic; 111 | } 112 | 113 | .token.entity { 114 | cursor: help; 115 | } 116 | 117 | .token.inserted { 118 | color: green; 119 | } 120 | 121 | pre[class*="language-"].line-numbers { 122 | position: relative; 123 | padding-left: 3.8em; 124 | counter-reset: linenumber; 125 | } 126 | 127 | pre[class*="language-"].line-numbers > code { 128 | position: relative; 129 | white-space: inherit; 130 | } 131 | 132 | .line-numbers .line-numbers-rows { 133 | position: absolute; 134 | pointer-events: none; 135 | top: 0; 136 | font-size: 100%; 137 | left: -3.8em; 138 | width: 3em; /* works for line-numbers below 1000 lines */ 139 | letter-spacing: -1px; 140 | border-right: 1px solid #999; 141 | 142 | -webkit-user-select: none; 143 | -moz-user-select: none; 144 | -ms-user-select: none; 145 | user-select: none; 146 | } 147 | 148 | .line-numbers-rows > span { 149 | pointer-events: none; 150 | display: block; 151 | counter-increment: linenumber; 152 | } 153 | 154 | .line-numbers-rows > span:before { 155 | content: counter(linenumber); 156 | color: #999; 157 | display: block; 158 | padding-right: 0.8em; 159 | text-align: right; 160 | } 161 | -------------------------------------------------------------------------------- /src/test/scala/intent/matchers/FailureTest.scala: -------------------------------------------------------------------------------- 1 | 2 | package intent.matchers 3 | 4 | import intent._ 5 | import intent.helpers.Meta 6 | import intent.core.TestTimeout 7 | import scala.concurrent.{Future, Promise} 8 | import scala.concurrent.duration._ 9 | 10 | class FailureTest extends TestSuite with Stateless with Meta: 11 | "a toEqual failure": 12 | "is described properly" in: 13 | runExpectation(expect(1).toEqual(2), "Expected 2 but found 1") 14 | 15 | "is described properly in the negative" in: 16 | runExpectation(expect(1).not.toEqual(1), "Expected 1 not to equal 1") 17 | 18 | "is described properly with Option" in: 19 | runExpectation(expect(Some(1)).toEqual(None), "Expected None but found Some(1)") 20 | 21 | "is described properly for Iterables, when actual is longer" in: 22 | runExpectation(expect(Seq(1, 2)).toEqual(Seq(1)), 23 | "Expected List(1, 2) to equal List(1)") 24 | 25 | "is described properly for Iterables, when actual is shorter" in: 26 | runExpectation(expect(Seq(1, 2)).toEqual(Seq(1, 2, 3)), 27 | "Expected List(1, 2) to equal List(1, 2, 3)") 28 | 29 | "is described properly for Arrays" in: 30 | runExpectation(expect(Array(1, 2)).toEqual(Array(1)), 31 | "Expected Array(1, 2) to equal Array(1)") 32 | 33 | "is described properly for Array-Iterable" in: 34 | runExpectation(expect(Array(1, 2)).toEqual(Seq(1)), 35 | "Expected Array(1, 2) to equal List(1)") 36 | 37 | "with custom formatting" in: 38 | given customIntFmt as core.Formatter[Int]: 39 | def format(a: Int): String = a.toString.replace("4", "forty-") 40 | runExpectation(expect(42).toEqual(43), 41 | "Expected forty-3 but found forty-2") 42 | 43 | "a toMatch failure": 44 | "is described properly" in: 45 | runExpectation(expect("foobar").toMatch("^bar".r), "Expected \"foobar\" to match /^bar/") 46 | 47 | "is described properly in the negative" in: 48 | runExpectation(expect("foobar").not.toMatch("^foo".r), "Expected \"foobar\" not to match /^foo/") 49 | 50 | "a toContain failure": 51 | "is described properly" in: 52 | runExpectation(expect(Seq(1, 2)).toContain(3), "Expected List(1, 2) to contain 3") 53 | 54 | "is described properly in the negative" in: 55 | runExpectation(expect(Seq(1, 2)).not.toContain(1), "Expected List(1, ...) not to contain 1") 56 | 57 | "is described properly for Array" in: 58 | runExpectation(expect(Array(1, 2)).toContain(3), "Expected Array(1, 2) to contain 3") 59 | 60 | "is described properly when item is found in infinite stream but it's not expected" in: 61 | val s = LazyList.from(1) 62 | runExpectation(expect(s).not.toContain(4), "Expected LazyList(1, 2, 3, 4, ...) not to contain 4") 63 | 64 | "is described properly when item is not found in infinite stream but it's expected" in: 65 | val s = LazyList.from(1) 66 | runExpectation(expect(s).toContain(-1), "Expected LazyList(1, 2, 3, 4, 5, ...) to contain -1") 67 | 68 | "using fail()": 69 | "should fail with the given description" in runExpectation(fail("Manually failed"), "Manually failed") 70 | 71 | "Future timeout": 72 | "should abort a long-running test when whenComplete is used" in: 73 | given customTimeout as TestTimeout = TestTimeout(50.millis) 74 | val p = Promise[Int]() 75 | runExpectation({ 76 | whenComplete(p.future): 77 | result => expect(result).toEqual(42) 78 | }, "Test timed out") 79 | 80 | "should abort a long-running test when toCompleteWith is used" in: 81 | given customTimeout as TestTimeout = TestTimeout(50.millis) 82 | val p = Promise[Int]() 83 | runExpectation({ 84 | expect(p.future).toCompleteWith(42) 85 | }, "Test timed out") 86 | -------------------------------------------------------------------------------- /src/test/scala/intent/matchers/ToThrowTest.scala: -------------------------------------------------------------------------------- 1 | package intent.matchers 2 | 3 | import intent.{Stateless, TestSuite} 4 | import intent.helpers.Meta 5 | 6 | class ToThrowTest extends TestSuite with Stateless with Meta: 7 | "toThrow": 8 | "with only exception type": 9 | "should find match" in expect(throwIllegalArg).toThrow[IllegalArgumentException]() 10 | 11 | "should find negated match" in expect(throwIllegalState).not.toThrow[IllegalArgumentException]() 12 | 13 | "should handle NPE" in expect(throwNPE).toThrow[NullPointerException]() 14 | 15 | "should detect wrong exception" in: 16 | runExpectation(expect(throwIllegalState).toThrow[IllegalArgumentException](), 17 | "Expected the code to throw java.lang.IllegalArgumentException, but it threw java.lang.IllegalStateException") 18 | 19 | "should detect wrong exception when negated" in: 20 | runExpectation(expect(throwIllegalState).not.toThrow[IllegalStateException](), 21 | "Expected the code not to throw java.lang.IllegalStateException, but it did") 22 | 23 | "should detect absence of exception" in: 24 | runExpectation(expect(dontThrow).toThrow[IllegalArgumentException](), 25 | "Expected the code to throw java.lang.IllegalArgumentException, but it did not throw anything") 26 | 27 | "should detect absence of exception when negated" in: 28 | expect(dontThrow).not.toThrow[IllegalArgumentException]() 29 | 30 | "should detect wrong exception and retain the exception" in: 31 | runExpectation(expect(throwIllegalState).toThrow[IllegalArgumentException](), { 32 | case t: IllegalStateException => t.getMessage == "state error" 33 | }) 34 | 35 | "should find when a sub class is thrown" in expect(throwIllegalArg).toThrow[RuntimeException]() 36 | 37 | "should find when a sub class is thrown, when negated" in: 38 | runExpectation(expect(throwIllegalState).not.toThrow[RuntimeException](), 39 | "Expected the code not to throw java.lang.RuntimeException, but it threw java.lang.IllegalStateException") 40 | 41 | "with exception type and expected message": 42 | "should find match" in expect(throwIllegalArg).toThrow[IllegalArgumentException]("arg error") 43 | "should find negated match" in expect(throwIllegalArg).not.toThrow[IllegalArgumentException]("wrong text") 44 | "should handle NPE" in expect(throwNPE).toThrow[NullPointerException](null.asInstanceOf[String]) 45 | 46 | "should describe when type is correct but message is not" in: 47 | runExpectation(expect(throwIllegalArg).toThrow[IllegalArgumentException]("something else"), 48 | "Expected the code to throw java.lang.IllegalArgumentException with message \"something else\", but the message was \"arg error\"") 49 | 50 | "should describe wrong exception+message when negated" in: 51 | runExpectation(expect(throwIllegalState).not.toThrow[IllegalStateException]("state error"), 52 | "Expected the code not to throw java.lang.IllegalStateException with message \"state error\", but it did") 53 | 54 | 55 | "with exception type and expected RegExp message": 56 | "should find match" in expect(throwIllegalArg).toThrow[IllegalArgumentException]("er+".r) 57 | "should find negated match" in expect(throwIllegalArg).not.toThrow[IllegalArgumentException]("er[^r]") 58 | 59 | "should describe when type is correct but message is not" in: 60 | runExpectation(expect(throwIllegalArg).toThrow[IllegalArgumentException]("^x".r), 61 | "Expected the code to throw java.lang.IllegalArgumentException with message matching /^x/, but the message was \"arg error\"") 62 | 63 | def throwIllegalArg = throw IllegalArgumentException("arg error") 64 | def throwIllegalState = throw IllegalStateException("state error") 65 | def throwNPE = throw NullPointerException() 66 | def dontThrow = "tada" 67 | -------------------------------------------------------------------------------- /site/config.toml: -------------------------------------------------------------------------------- 1 | baseURL = "https://factor10.github.io/intent" 2 | languageCode = "en-us" 3 | title = "Intent" 4 | theme = "intent" 5 | 6 | pygmentsuseclasses = false 7 | 8 | [params] 9 | version = "0.6.0" 10 | 11 | [menu] 12 | 13 | [[menu.main]] 14 | identifier = "introduction" 15 | name = "Introduction" 16 | url = "/" 17 | weight = 100 18 | 19 | [[menu.main]] 20 | parent = "introduction" 21 | name = "Getting started" 22 | url = "#getting-started" 23 | weight = 100 24 | 25 | [[menu.main]] 26 | parent = "introduction" 27 | name = "Why a new framework" 28 | url = "#why-a-new-test-framework" 29 | weight = 110 30 | 31 | [[menu.main]] 32 | parent = "introduction" 33 | name = "Contributing" 34 | url = "#contributing-to-intent" 35 | weight = 120 36 | 37 | [[menu.main]] 38 | parent = "introduction" 39 | name = "Find us at GitHub" 40 | url = "https://github.com/factor10/intent" 41 | weight = 120 42 | 43 | [[menu.main]] 44 | identifier = "types" 45 | name = "Types of tests" 46 | url = "/types-of-tests/stateless/" 47 | weight = 200 48 | 49 | [[menu.main]] 50 | parent = "types" 51 | name = "Stateless" 52 | url = "/types-of-tests/stateless/" 53 | weight = 100 54 | 55 | [[menu.main]] 56 | parent = "types" 57 | name = "Stateful" 58 | url = "/types-of-tests/stateful/" 59 | weight = 110 60 | 61 | [[menu.main]] 62 | parent = "types" 63 | name = "Asynchronous" 64 | url = "/types-of-tests/asynchronous/" 65 | weight = 120 66 | 67 | [[menu.main]] 68 | parent = "types" 69 | name = "Table-driven" 70 | url = "/types-of-tests/table-driven/" 71 | weight = 130 72 | 73 | [[menu.main]] 74 | identifier = "matchers" 75 | name = "Matchers" 76 | url = "/matchers/" 77 | weight = 300 78 | 79 | [[menu.main]] 80 | parent = "matchers" 81 | name = ".toEqual" 82 | url = "/matchers/#toequal" 83 | weight = 110 84 | 85 | [[menu.main]] 86 | parent = "matchers" 87 | name = ".toHaveLength" 88 | url = "/matchers/#tohavelength" 89 | weight = 120 90 | 91 | [[menu.main]] 92 | parent = "matchers" 93 | name = ".toContain" 94 | url = "/matchers/#tocontain" 95 | weight = 130 96 | 97 | [[menu.main]] 98 | parent = "matchers" 99 | name = ".toContainAllOf" 100 | url = "/matchers/#tocontainallof" 101 | weight = 140 102 | 103 | [[menu.main]] 104 | parent = "matchers" 105 | name = ".toCompleteWith" 106 | url = "/matchers/#tocompletewith" 107 | weight = 150 108 | 109 | [[menu.main]] 110 | parent = "matchers" 111 | name = ".toMatch" 112 | url = "/matchers/#tomatch" 113 | weight = 160 114 | 115 | [[menu.main]] 116 | parent = "matchers" 117 | name = ".toThrow" 118 | url = "/matchers/#tothrow" 119 | weight = 170 120 | 121 | [[menu.main]] 122 | identifier = "customization" 123 | name = "Customization" 124 | url = "/customization/" 125 | weight = 400 126 | 127 | [[menu.main]] 128 | parent = "customization" 129 | name = "Manually fail or succeed" 130 | url = "/customization/#manually-fail-or-succeed" 131 | weight = 100 132 | 133 | [[menu.main]] 134 | parent = "customization" 135 | name = "Asyncness" 136 | url = "/customization/#asyncness" 137 | weight = 110 138 | 139 | [[menu.main]] 140 | parent = "customization" 141 | name = "Equality" 142 | url = "/customization/#equality" 143 | weight = 120 144 | 145 | [[menu.main]] 146 | parent = "customization" 147 | name = "Floating-point precision" 148 | url = "/customization/#floating-point-precision" 149 | weight = 130 150 | 151 | [[menu.main]] 152 | parent = "customization" 153 | name = "Formatting" 154 | url = "/customization/#formatting" 155 | weight = 140 156 | 157 | [[menu.main]] 158 | parent = "customization" 159 | name = "Timeout" 160 | url = "/customization/#timeout" 161 | weight = 150 162 | -------------------------------------------------------------------------------- /src/main/scala/intent/core/expectations/equal.scala: -------------------------------------------------------------------------------- 1 | package intent.core.expectations 2 | 3 | import intent.core._ 4 | import scala.concurrent.Future 5 | import scala.collection.mutable.ListBuffer 6 | import scala.{Array, Iterable} 7 | 8 | private def evalToEqual[T](actual: Iterable[T], 9 | expected: Iterable[T], 10 | expect: Expect[_], 11 | actualListTypeName: String, 12 | expectedListTypeName: String) 13 | (using 14 | eqq: Eq[T], 15 | fmt: Formatter[T] 16 | ): Future[ExpectationResult] = 17 | 18 | def emptyIterator: Iterator[T] = Seq.empty[T].iterator 19 | def printContents(lb: ListBuffer[String], it: Iterable[T]) = 20 | if it eq null then "" else lb.mkString("(", ", ", ")") 21 | 22 | val areSameOk = (actual eq expected) && !expect.isNegated 23 | if areSameOk then 24 | // Shortcut the logic below. This allows us to test that an infinite list is 25 | // equal to itself. 26 | return Future.successful(expect.pass) 27 | 28 | val actualFormatted = ListBuffer[String]() 29 | val expectedFormatted = ListBuffer[String]() 30 | val actualIterator = Option(actual).map(_.iterator).getOrElse(emptyIterator) 31 | val expectedIterator = Option(expected).map(_.iterator).getOrElse(emptyIterator) 32 | var mismatch = false 33 | // TODO: Handle very long / infinite collections 34 | while !mismatch && actualIterator.hasNext && expectedIterator.hasNext do 35 | val actualNext = actualIterator.next() 36 | val expectedNext = expectedIterator.next() 37 | actualFormatted += fmt.format(actualNext) 38 | expectedFormatted += fmt.format(expectedNext) 39 | if !eqq.areEqual(actualNext, expectedNext) then 40 | mismatch = true 41 | 42 | // Must check if one (but not the other) is null, since we use an empty-iterator 43 | // fallback above (so null gets treated as empty list above). 44 | val oneIsNull = (actual eq null) ^ (expected eq null) 45 | val hasDiff = mismatch || actualIterator.hasNext || expectedIterator.hasNext || oneIsNull 46 | val allGood = if expect.isNegated then hasDiff else !hasDiff 47 | 48 | val r = if !allGood then 49 | 50 | // Collect the rest of the collections, if needed 51 | while actualIterator.hasNext || expectedIterator.hasNext do 52 | if actualIterator.hasNext then actualFormatted += fmt.format(actualIterator.next()) 53 | if expectedIterator.hasNext then expectedFormatted += fmt.format(expectedIterator.next()) 54 | 55 | val actualStr = actualListTypeName + printContents(actualFormatted, actual) 56 | val expectedStr = expectedListTypeName + printContents(expectedFormatted, expected) 57 | 58 | val desc = if expect.isNegated then 59 | s"Expected $actualStr to not equal $expectedStr" 60 | else 61 | s"Expected $actualStr to equal $expectedStr" 62 | expect.fail(desc) 63 | else expect.pass 64 | Future.successful(r) 65 | 66 | class EqualExpectation[T](expect: Expect[T], expected: T)(using eqq: Eq[T], fmt: Formatter[T]) extends Expectation: 67 | 68 | def evaluate(): Future[ExpectationResult] = 69 | val actual = expect.evaluate() 70 | 71 | var comparisonResult = eqq.areEqual(actual, expected) 72 | if expect.isNegated then comparisonResult = !comparisonResult 73 | 74 | val r = if !comparisonResult then 75 | val actualStr = fmt.format(actual) 76 | val expectedStr = fmt.format(expected) 77 | 78 | val desc = if expect.isNegated then 79 | s"Expected $actualStr not to equal $expectedStr" 80 | else 81 | s"Expected $expectedStr but found $actualStr" 82 | expect.fail(desc) 83 | else expect.pass 84 | Future.successful(r) 85 | 86 | class IterableEqualExpectation[T](expect: Expect[Iterable[T]], expected: Iterable[T])(using eqq: Eq[T], fmt: Formatter[T]) extends Expectation: 87 | 88 | def evaluate(): Future[ExpectationResult] = 89 | val actual = expect.evaluate() 90 | evalToEqual(actual, expected, expect, listTypeName(actual), listTypeName(expected)) 91 | 92 | 93 | class ArrayEqualExpectation[T](expect: Expect[Array[T]], expected: Iterable[T])(using eqq: Eq[T], fmt: Formatter[T]) extends Expectation: 94 | 95 | def evaluate(): Future[ExpectationResult] = 96 | val actual = expect.evaluate() 97 | evalToEqual(actual, expected, expect, "Array", listTypeName(expected)) 98 | -------------------------------------------------------------------------------- /docs/types-of-tests/index.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Types-of-tests on Intent 5 | https://factor10.github.io/intent/types-of-tests/ 6 | Recent content in Types-of-tests on Intent 7 | Hugo -- gohugo.io 8 | en-us 9 | Thu, 09 Apr 2020 13:44:39 +0200 10 | 11 | 12 | 13 | 14 | 15 | Asynchronous tests 16 | https://factor10.github.io/intent/types-of-tests/asynchronous/ 17 | Thu, 09 Apr 2020 13:44:39 +0200 18 | 19 | https://factor10.github.io/intent/types-of-tests/asynchronous/ 20 | Asynchronous tests Intent supports stateful tests where the state is produced asynchronously. An example: 21 | class AsyncStatefulTest extends TestSuite with AsyncState[AsyncStatefulState]: &#34;an empty cart&#34; using Cart() to : &#34;with two items&#34; usingAsync (_.add(CartItem(&#34;beach-chair&#34;, 2))) to : &#34;and another three items&#34; usingAsync (_.add(CartItem(&#34;sunscreen&#34;, 3))) to : &#34;calculates total price&#34; in : cart =&gt; expect(cart.totalPrice).toEqual(275.0d) case class CartItem(artNo: String, qty: Int) case class PricedCartItem(item: CartItem, price: Double): def totalPrice = item.qty * price case class Cart(items: Seq[PricedCartItem] = Seq. 22 | 23 | 24 | 25 | Stateful tests 26 | https://factor10.github.io/intent/types-of-tests/stateful/ 27 | Thu, 09 Apr 2020 13:44:39 +0200 28 | 29 | https://factor10.github.io/intent/types-of-tests/stateful/ 30 | Stateful tests Not all tests can be implemented without setting the scene, still many test frameworks only focus on a expressive way to assert expectations. For Intent state management is front and center. 31 | Lets go straight to the code: 32 | class StatefulTest extends TestSuite with State[Cart]: &#34;an empty cart&#34; using Cart() to : &#34;with two items&#34; using (_.add(CartItem(&#34;beach-chair&#34;, 2))) to : &#34;and another three items&#34; using (_.add(CartItem(&#34;sunscreen&#34;, 3))) to : &#34;contains 5 items&#34; in : cart =&gt; expect(cart. 33 | 34 | 35 | 36 | Table-driven tests 37 | https://factor10.github.io/intent/types-of-tests/stateless/ 38 | Thu, 09 Apr 2020 13:44:39 +0200 39 | 40 | https://factor10.github.io/intent/types-of-tests/stateless/ 41 | Table-driven tests A stateless test suite extends the Statesless suite. Contexts in this style serve no other purpose than grouping tests into logical units. 42 | Consider the following example: 43 | import intent.{Stateless, TestSuite} class CalculatorTest extends TestSuite with Stateless: &#34;A calculator&#34; : &#34;can add&#34; : &#34;plain numbers&#34; in expect(Calculator().add(2, 4)).toEqual(6) &#34;complex numbers&#34; in: val a = Complex(2, 3) val b = Complex(3, 4) expect(Calculator().add(a, b)).toEqual(Complex(5, 7)) &#34;can multiply&#34; : &#34;plain numbers&#34; in expect(Calculator(). 44 | 45 | 46 | 47 | Table-driven tests 48 | https://factor10.github.io/intent/types-of-tests/table-driven/ 49 | Thu, 09 Apr 2020 13:44:39 +0200 50 | 51 | https://factor10.github.io/intent/types-of-tests/table-driven/ 52 | Table-driven tests Table-driven tests allow for a declarative approach to writing tests, and is useful to test many different variations of some feature with as little boilerplate as possible. 53 | For example, consider the following test suite that tests a Fibonacci function: 54 | class FibonacciTest extends TestSuite with State[TableState]: &#34;The Fibonacci function&#34; usingTable (examples) to : &#34;works&#34; in : example =&gt; expect(F(example.n)).toEqual(example.expected) def examples = Seq( FibonacciExample(0, 0), FibonacciExample(1, 1), FibonacciExample(2, 1), FibonacciExample(3, 2), FibonacciExample(12, 144) ) def F(n: Int): Int = . 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /src/main/scala/intent/core/expectations/contain.scala: -------------------------------------------------------------------------------- 1 | package intent.core.expectations 2 | 3 | import intent.core._ 4 | import intent.core.MapLike 5 | import scala.concurrent.Future 6 | import scala.collection.mutable.ListBuffer 7 | import scala.Array 8 | 9 | private def evalToContain[T](actual: IterableOnce[T], 10 | expected: T, 11 | expect: Expect[_], 12 | listTypeName: String) 13 | (using 14 | eqq: Eq[T], 15 | fmt: Formatter[T], 16 | cutoff: ListCutoff 17 | ): Future[ExpectationResult] = 18 | 19 | def emptyIterator: Iterator[T] = Seq.empty[T].iterator 20 | def printContents(lb: ListBuffer[String]) = 21 | if actual == null then "" else lb.mkString("(", ", ", ")") 22 | 23 | val seen = ListBuffer[String]() 24 | var found = false 25 | val iterator = Option(actual).map(_.iterator).getOrElse(emptyIterator) 26 | val shouldNotFind = expect.isNegated 27 | var breakEarly = false 28 | var itemsChecked = 0 29 | while !breakEarly && iterator.hasNext do 30 | if itemsChecked >= cutoff.maxItems then 31 | breakEarly = true 32 | // This results in failure output like: List(X, ...) 33 | seen.takeInPlace(cutoff.printItems) 34 | else 35 | val next = iterator.next() 36 | seen += fmt.format(next) 37 | if !found && eqq.areEqual(next, expected) then 38 | found = true 39 | if shouldNotFind then 40 | breakEarly = true 41 | itemsChecked += 1 42 | 43 | val allGood = if expect.isNegated then !found else found 44 | 45 | val r = if !allGood then 46 | if iterator.hasNext then 47 | seen += "..." 48 | val actualStr = listTypeName + printContents(seen) 49 | val expectedStr = fmt.format(expected) 50 | 51 | val desc = if expect.isNegated then 52 | s"Expected $actualStr not to contain $expectedStr" 53 | else 54 | s"Expected $actualStr to contain $expectedStr" 55 | expect.fail(desc) 56 | else expect.pass 57 | Future.successful(r) 58 | 59 | class IterableContainExpectation[T](expect: Expect[IterableOnce[T]], expected: T)( 60 | using 61 | eqq: Eq[T], 62 | fmt: Formatter[T], 63 | cutoff: ListCutoff 64 | ) extends Expectation: 65 | 66 | def evaluate(): Future[ExpectationResult] = 67 | val actual = expect.evaluate() 68 | evalToContain(actual, expected, expect, listTypeName(actual)) 69 | 70 | class ArrayContainExpectation[T](expect: Expect[Array[T]], expected: T)( 71 | using 72 | eqq: Eq[T], 73 | fmt: Formatter[T], 74 | cutoff: ListCutoff 75 | ) extends Expectation: 76 | 77 | def evaluate(): Future[ExpectationResult] = 78 | val actual = expect.evaluate() 79 | evalToContain(actual, expected, expect, "Array") 80 | 81 | class MapContainExpectation[K, V](expect: Expect[MapLike[K, V]], expected: Seq[Tuple2[K, V]])( 82 | using 83 | eqq: Eq[V], 84 | keyFmt: Formatter[K], 85 | valueFmt: Formatter[V], 86 | ) extends Expectation: 87 | 88 | def evaluate(): Future[ExpectationResult] = 89 | val actual = expect.evaluate() 90 | var failingKeys = Seq[Tuple2[K, V]]() // When the key failing (missing or present when negated) 91 | var failingValues = Seq[Tuple3[K, V, V]]() // When the key is OK but the value does not match 92 | 93 | expected.foreach { expectedPair => 94 | actual.get(expectedPair._1) match { 95 | case None if expect.isNegated => 96 | () // OK = NOOP 97 | case None => 98 | failingKeys :+= expectedPair 99 | case Some(v) if expect.isNegated => 100 | failingKeys :+= expectedPair 101 | case Some(v) => 102 | if !eqq.areEqual(v, expectedPair._2) then failingValues :+= (expectedPair._1, expectedPair._2, v) 103 | } 104 | } 105 | 106 | Future.successful: 107 | if failingKeys.isEmpty && failingValues.isEmpty then 108 | expect.pass 109 | else if expect.isNegated && failingKeys.length < expected.length then 110 | // If only some of the pairs were missing, the negated expect is fulfilled, i.e. actual does not, not.containAllOf 111 | // This type of negation should be avoided in tests though, as the intention is not clear 112 | expect.pass 113 | else 114 | var message = s"Expected ${describeActualWithContext(actual)} to " 115 | if (expect.isNegated) message += "not " 116 | message += "contain:\n " 117 | message += failingKeys.map(p => s"${keyFmt.format(p._1)} -> ${valueFmt.format(p._2)}").mkString("\n ") 118 | message += failingValues.map(p => s"${keyFmt.format(p._1)} -> ${valueFmt.format(p._2)} but found ${keyFmt.format(p._1)} -> ${valueFmt.format(p._3)}").mkString("\n ") 119 | expect.fail(message) 120 | 121 | private def describeActualWithContext(actual: MapLike[K, V]): String = "Map(...)" 122 | -------------------------------------------------------------------------------- /src/main/scala/intent/runner/TestSuiteRunner.scala: -------------------------------------------------------------------------------- 1 | package intent.runner 2 | 3 | import scala.collection.immutable.Stream 4 | import scala.concurrent.duration._ 5 | import scala.concurrent.{ExecutionContext, Future, Promise} 6 | import scala.util.{Try,Success,Failure} 7 | 8 | import intent.core.{IntentStructure, ExpectationResult, TestSuite, TestCaseResult, 9 | TestPassed, TestFailed, TestError, TestIgnored, Subscriber} 10 | 11 | /** 12 | * Fatal error during construction or loading (including test discovery) of a test suite. 13 | */ 14 | case class TestSuiteError(ex: Throwable) extends Throwable 15 | 16 | /** 17 | * A successful test suite result. 18 | * 19 | * @param total The total number of tests run 20 | * @param successful The number of successful tests 21 | * @param failed The number of failed tests (when assertion failed) 22 | * @param errors The number of tests that caused error (exception, not failed assertion) 23 | * @param ignored The number of tests that are ignored (not run) 24 | */ 25 | case class TestSuiteResult(total: Int = 0, successful: Int = 0, failed: Int = 0, errors: Int = 0, ignored: Int = 0): 26 | def incSuccess(): TestSuiteResult = this.copy( 27 | total = total + 1, 28 | successful = successful + 1) 29 | 30 | def incFailure(): TestSuiteResult = this.copy( 31 | total = total + 1, 32 | failed = failed + 1) 33 | 34 | def incError(): TestSuiteResult = this.copy( 35 | total = total + 1, 36 | errors = errors + 1) 37 | 38 | def incIgnore(): TestSuiteResult = this.copy( 39 | total = total + 1, 40 | ignored = ignored +1) 41 | 42 | /** 43 | * A test suite runner. 44 | * 45 | * The runner will not load any suites until a specific class is requested to be run. The runner does not 46 | * keep state between runs so it is safe to reuse the runner for multiple suites if perferred. 47 | * 48 | * @param classLoader The class loader used to load and instantiate the test suite 49 | */ 50 | class TestSuiteRunner(classLoader: ClassLoader): 51 | /** 52 | * Instantiate and run the given test suite. 53 | * 54 | * The method will eventually resolve with either a [[TestSuiteResult]] upon success, else [[TestSuiteError]] will 55 | * be returned with details on the error that occured. 56 | * 57 | * Until the full suite is executed a subscriber can be given to receive events during the test-run. A custom 58 | * subscriber is also recommended if more details than the successful result is needed. 59 | */ 60 | def runSuite(className: String, eventSubscriber: Option[Subscriber[TestCaseResult]] = None)(using ec: ExecutionContext): Future[Either[TestSuiteError, TestSuiteResult]] = 61 | instantiateSuite(className) match 62 | case Success(instance) => runTestsForSuite(instance, eventSubscriber).map(res => Right(res)) 63 | case Failure(ex: Throwable) => Future.successful(Left(TestSuiteError(ex))) 64 | 65 | /** 66 | * Instantiate the given test suite and evaluate which tests that should be run or ignored 67 | */ 68 | private[intent] def evaluateSuite(className: String): Either[TestSuiteError, IntentStructure] = 69 | instantiateSuite(className) match 70 | case Success(instance) => Right(instance) 71 | case Failure(ex: Throwable) => Left(TestSuiteError(ex)) 72 | 73 | private def runTestsForSuite(suite: IntentStructure, eventSubscriber: Option[Subscriber[TestCaseResult]])(using ec: ExecutionContext): Future[TestSuiteResult] = 74 | // TODO: We should measure Suite time as well. Might be good to find expensive setup or scheduling problems. 75 | val futureTestResults = suite.allTestCases.map(tc => 76 | eventSubscriber match { 77 | case Some(subscriber) => 78 | val promise = Promise[TestCaseResult]() 79 | tc.run().onComplete: 80 | case Success(result) => 81 | subscriber.onNext(result) 82 | promise.success(result) 83 | case Failure(ex) => 84 | // TODO: Should we wrap the error in the result or should we add `onError()`? 85 | promise.failure(ex) 86 | promise.future 87 | case None => tc.run() 88 | }) 89 | 90 | // Aggregate result now that all tests are run 91 | Future.sequence(futureTestResults).map(all => all.foldLeft(TestSuiteResult())((acc: TestSuiteResult, res: TestCaseResult) => { 92 | res.expectationResult match 93 | case success: TestPassed => acc.incSuccess() 94 | case failure: TestFailed => acc.incFailure() 95 | case error: TestError => acc.incError() 96 | case ignored: TestIgnored => acc.incIgnore() 97 | case null => throw new IllegalStateException("Unsupported test result: null") 98 | })) 99 | 100 | private def instantiateSuite(className: String): Try[IntentStructure] = 101 | Try(classLoader.loadClass(className) 102 | .getDeclaredConstructor() 103 | .newInstance().asInstanceOf[IntentStructure]) 104 | -------------------------------------------------------------------------------- /src/test/scala/intent/matchers/ToEqualTest.scala: -------------------------------------------------------------------------------- 1 | package intent.matchers 2 | 3 | import intent.{Stateless, TestSuite} 4 | import scala.util.{Success, Failure, Try} 5 | import intent.helpers.Meta 6 | 7 | class ToEqualTest extends TestSuite with Stateless with Meta: 8 | "toEqual": 9 | 10 | "for Boolean": 11 | "true should equal true" in expect(true).toEqual(true) 12 | 13 | "true should *not* equal false" in expect(true).not.toEqual(false) 14 | 15 | "false should equal false" in expect(false).toEqual(false) 16 | 17 | "false should *note* equal true" in expect(false).not.toEqual(true) 18 | 19 | "for String": 20 | " should equal " in expect("").toEqual("") 21 | 22 | " should equal " in expect("foo").toEqual("foo") 23 | 24 | " should *not* equal " in expect("foo").not.toEqual("bar") 25 | 26 | "<🤓> should equal <🤓>" in expect("🤓").toEqual("🤓") 27 | 28 | "handles as expected" in expect("").not.toEqual(null.asInstanceOf[String]) 29 | 30 | "handles as actual" in expect(null.asInstanceOf[String]).not.toEqual("") 31 | 32 | "for Char": 33 | "should support equality test" in expect('a').toEqual('a') 34 | "should support inequality test" in expect('a').not.toEqual('A') 35 | 36 | "for Double": 37 | "should support equality test" in expect(3.14d).toEqual(3.14d) 38 | "should support inequality test" in expect(3.14d).not.toEqual(2.72d) 39 | 40 | "with precision": 41 | "should test equal with 12 decimals" in expect(1.123456789123d).toEqual(1.123456789123d) 42 | "should allow diff in the 13th decimal" in expect(1.1234567891234d).toEqual(1.1234567891235d) 43 | "should allow customization of precision" in: 44 | given customPrecision as intent.core.FloatingPointPrecision[Double]: 45 | def numberOfDecimals: Int = 2 46 | expect(1.234d).toEqual(1.235d) 47 | 48 | "for Float": 49 | "should support equality test" in expect(3.14f).toEqual(3.14f) 50 | "should support inequality test" in expect(3.14f).not.toEqual(2.72f) 51 | 52 | "with precision": 53 | "should test equal with 6 decimals" in expect(1.123456f).toEqual(1.123456f) 54 | "should allow diff in the 7th decimal" in expect(1.1234567f).toEqual(1.1234568f) 55 | "should allow customization of precision" in: 56 | given customPrecision as intent.core.FloatingPointPrecision[Float]: 57 | def numberOfDecimals: Int = 2 58 | expect(1.234f).toEqual(1.235f) 59 | 60 | "for Option": 61 | "Some should equal Some" in expect(Some(42)).toEqual(Some(42)) 62 | "Some should test inner equality" in expect(Some(42)).not.toEqual(Some(43)) 63 | "Some should not equal None" in expect(Some(42)).not.toEqual(None) 64 | "None should equal None" in expect[Option[String]](None).toEqual(None) 65 | "should consider custom equality" in: 66 | given customIntEq as intent.core.Eq[Int]: 67 | def areEqual(a: Int, b: Int) = Math.abs(a - b) == 1 68 | expect(Some(42)).toEqual(Some(43)) 69 | 70 | "for Try": 71 | "Success should equal Success" in expect[Try[Int]](Success(42)).toEqual(Success(42)) 72 | "Success should test inner equality" in expect[Try[Int]](Success(42)).not.toEqual(Success(43)) 73 | "Success should not equal Failure" in expect[Try[Int]](Success(42)).not.toEqual(Failure(new Exception("oops"))) 74 | "Failure should equal Failure" in: 75 | val ex = RuntimeException("oops") 76 | expect[Try[Int]](Failure(ex)).toEqual(Failure(ex)) 77 | "Failure should test inner equality" in: 78 | val ex1 = RuntimeException("oops1") 79 | val ex2 = RuntimeException("oops2") 80 | expect[Try[Int]](Failure(ex1)).not.toEqual(Failure(ex2)) 81 | 82 | "for Long": 83 | "should support equality test" in expect(10L).toEqual(10L) 84 | "should support inequality test" in expect(10L).not.toEqual(11L) 85 | 86 | "for collection": 87 | "supports equality test" in expect(Seq(1, 2)).toEqual(Seq(1, 2)) 88 | "detects inquality in shorter length" in expect(Seq(1, 2)).not.toEqual(Seq(1)) 89 | "detects inquality in longer length" in expect(Seq(1, 2)).not.toEqual(Seq(1, 2, 3)) 90 | "detects inquality in item" in expect(Seq(1, 2)).not.toEqual(Seq(1, 3)) 91 | "supports equality with same LazyList" in: 92 | val list = LazyList.from(1) 93 | expect(list).toEqual(list) 94 | 95 | "handles as expected" in expect(Seq.empty[Int]).not.toEqual(null.asInstanceOf[Seq[Int]]) 96 | 97 | "handles as actual" in expect(null.asInstanceOf[Seq[Int]]).not.toEqual(Seq.empty[Int]) 98 | 99 | "is described properly when actual is null" in: 100 | runExpectation(expect(null.asInstanceOf[Seq[Int]]).toEqual(Seq(1, 2, 3)), 101 | "Expected to equal List(1, 2, 3)") 102 | 103 | "is described properly when expected is null" in: 104 | runExpectation(expect(Seq(1, 2, 3)).toEqual(null.asInstanceOf[Seq[Int]]), 105 | "Expected List(1, 2, 3) to equal ") 106 | 107 | "is described properly when both are null" in: 108 | runExpectation(expect(null.asInstanceOf[Seq[Int]]).not.toEqual(null.asInstanceOf[Seq[Int]]), 109 | "Expected to not equal ") 110 | 111 | "for array": 112 | "supports equality test" in expect(Array(1, 2)).toEqual(Array(1, 2)) 113 | "detects inquality in item" in expect(Array(1, 2)).not.toEqual(Array(1, 3)) 114 | "supports equality test with non-array" in expect(Array(1, 2)).toEqual(Seq(1, 2)) 115 | -------------------------------------------------------------------------------- /site/content/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Intent - A test framework for Dotty" 3 | date: 2020-04-09T13:44:39+02:00 4 | draft: false 5 | --- 6 | 7 | Intent is a test framework for [Dotty](https://dotty.epfl.ch) (which is expected to become Scala 3 in year 2020). 8 | 9 | Intent is designed to give you clear and concise tests by focusing on: 10 | 11 | * Low ceremony test code 12 | * Uniform test declaration 13 | * Futures and async testing 14 | * Arranging test state 15 | * Fast to run tests 16 | 17 | 18 | Let us see how a test suite looks like for Intent: 19 | 20 | ```scala 21 | class StatefulTest extends TestSuite with State[Cart]: 22 | "an empty cart" using Cart() to : 23 | "with two items" using (_.add(CartItem("beach-chair", 2))) to : 24 | "and another three items" using (_.add(CartItem("sunscreen", 3))) to : 25 | "contains 5 items" in : 26 | cart => expect(cart.totalQuantity).toEqual(5) 27 | 28 | case class CartItem(artNo: String, qty: Int) 29 | 30 | case class Cart(items: Seq[CartItem] = Seq.empty): 31 | def add(item: CartItem): Cart = copy(items = items :+ item) 32 | def totalQuantity = items.map(_.qty).sum 33 | ``` 34 | 35 | ## Getting started 36 | 37 | Intent is built using Scala 3 (called Dotty) and is an early adopter of both new and 38 | experimental features. So assume that you will need a recent version of Dotty to use 39 | Intent. 40 | 41 | We'll try to state minimum required Dotty version in `README.md` (you can also find it 42 | in `build.sbt`) 43 | 44 | ### Setting up SBT 45 | 46 | The first thing you need to do is to add Intent to your SBT project with the following 47 | lines to your `build.sbt`: 48 | 49 | ```scala 50 | libraryDependencies += "com.factor10" %% "intent" % "0.6.0", 51 | testFrameworks += new TestFramework("intent.sbt.Framework") 52 | ``` 53 | 54 | ### Our first test 55 | 56 | Let's have a look at how tests should be written. 57 | 58 | ```scala 59 | import intent.{Stateless, TestSuite} 60 | 61 | class ToEqualTest extends TestSuite with Stateless: 62 | "toEqual" : 63 | "for Boolean" : 64 | "true should equal true" in expect(true).toEqual(true) 65 | "true should *not* equal false" in expect(true).not.toEqual(false) 66 | ``` 67 | 68 | All tests must belong to a test suite. A test suite is a class that extends 69 | `TestSuite` and in this case `Stateless` to indicate the tests do not depend 70 | on any state setup. 71 | 72 | _Stateful tests will be described shortly._ 73 | 74 | Extending `TestSuite` is required, since that is how SBT discovers your tests, 75 | that is its only purpose. 76 | 77 | 78 | #### Running tests 79 | 80 | Intent only supports SBT for running tests (at least for now) where you run tests 81 | using `sbt test` command. 82 | 83 | SBT will identify all your test suites that are stored under the default Scala 84 | test directory: `src/test` 85 | 86 | The test results are printed to STDOUT via the SBT log: 87 | 88 | ```log 89 | [info] [PASSED] ToEqualTest >> toEqual >> for Boolean >> true should *not* equal false (25 ms) 90 | [info] [PASSED] ToEqualTest >> toEqual >> for Boolean >> true should equal true (30 ms) 91 | ``` 92 | 93 | _Currently there are no reports other than the SBT output._ 94 | 95 | 96 | ## Why a new test framework? 97 | 98 | The idea of a new test framework was born out of both the frustration and inspiration 99 | of using existing frameworks. Having written tens of thousands of tests using a variety 100 | of languages and frameworks there are a few challenges that keep surfacing. 101 | 102 | **Structure** - when you have thousands of tests it is important that the ceremony to 103 | add a new test is as low as possible. If a test belongs to the same functionality as other 104 | tests, these tests should stay together. 105 | 106 | > Intent's goal is that it should be possible to express a simple test in a single line 107 | and still have that line clearly express the intention of the test. 108 | 109 | **State**, most tests are not stateless, instead they require setup code in order to get 110 | to the state of interest for a particular test. Setting up this state is often verbose, 111 | heavily imperative and worst of all repeated over and over again. 112 | 113 | > Intent's goal is to make the dependency on state obvious for each test, and to allow 114 | state transformation in a hierarchical structure. 115 | 116 | **Expectation**, when using fluent and nested matchers we feel that it increases the 117 | cognitive load when writing the tests. You need to know each and every one of the 118 | qualified behaviours until you get to the one actually performing the assert. Having too 119 | simple matchers on the other hand results in more test code, and therefore introduce more 120 | noise to achieve the same expectation. 121 | 122 | > Intent's goal is to make assertions easy to find and use while also supporting the 123 | most common expectations. 124 | 125 | Intent is built, not to circumvent these challenges, but to put them front and center. 126 | As we believe these three attributes are the most significant for achieving good quality 127 | test they should be the foundation of a test framework. 128 | 129 | It deserves to be said that Intent pays homage to in particular two test frameworks that 130 | has inspired us greatly: 131 | 132 | * Jasmine, supporting nested tests and the format of the expect / matchers 133 | * ScalaTest, FreeSpec style and the use of test fixtures 134 | 135 | 136 | ## Contributing to Intent 137 | 138 | The design of Intent and the structure of tests are still moving targets. 139 | Therefore, if you wish to contribute, please open an issue or comment on an 140 | existing issue so that we can have a discussion first. 141 | 142 | For any contribution, the following applies: 143 | 144 | * Tests must be added, if relevant. 145 | * Documentation must be added, if relevant. 146 | * In the absence of style guidelines, please stick to the existing style. 147 | If unsure what the existing style is, ask! :) 148 | -------------------------------------------------------------------------------- /docs/intent.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --violet-color: #7b70b0; 3 | --text-color: #252525; 4 | --fine-color: #a1a1a1; 5 | --link-color: #252525; 6 | --green-color: #5B9940; 7 | } 8 | 9 | html { 10 | font-family: 'Helvetica', sans-serif; 11 | color: var(--text-color); 12 | font-weight: 300; 13 | } 14 | 15 | h1, h2, h3, h4, h5, h6 { 16 | font-weight: 400; 17 | } 18 | 19 | header { 20 | padding: 20px 0; 21 | text-align: center; 22 | } 23 | 24 | header .intent-logo { 25 | text-align: center; 26 | } 27 | 28 | header .intent-logo h1 { 29 | font-weight: 200; 30 | margin-top: 10px; 31 | } 32 | 33 | header .intent-logo a, 34 | header .intent-logo a:active, 35 | header .intent-logo a:hover, 36 | header .intent-logo a:focus, 37 | header .intent-logo a:link { 38 | color: var(--text-color); 39 | text-decoration: none; 40 | border-bottom: none; 41 | } 42 | 43 | 44 | header a.intent-version, 45 | header a.intent-version:active, 46 | header a.intent-version:link 47 | header a.intent-version:focus, 48 | header a.intent-version:hover { 49 | color: var(--fine-color); 50 | font-size: smaller; 51 | font-weight: 300; 52 | text-decoration: none; 53 | border: none; 54 | } 55 | 56 | a, 57 | a:active, 58 | a:link { 59 | color: var(--text-color); 60 | text-decoration: none; 61 | border-bottom: 1px solid var(--text-color); 62 | } 63 | 64 | a:hover { 65 | color: var(--text-color); 66 | border-bottom: 2px solid var(--text-color); 67 | } 68 | 69 | a:focus { 70 | color: var(--text-color); 71 | outline: 0; 72 | border-bottom: 2px solid var(--text-color); 73 | } 74 | 75 | h3 { 76 | margin: 16px 0 8px 0; 77 | } 78 | 79 | code { 80 | padding: 3px; 81 | font-size: larger; 82 | } 83 | 84 | .significant { 85 | font-style: italic; 86 | } 87 | 88 | .intent-page { 89 | display: flex; 90 | } 91 | 92 | @media only screen and (max-width: 1200px) { 93 | .intent-page { 94 | display: flex; 95 | flex-direction: column; 96 | } 97 | } 98 | 99 | #menu-checkbox { 100 | display: none; 101 | } 102 | 103 | @media only screen and (max-width: 1200px) { 104 | .intent-page-toc-chapter { 105 | display: none; 106 | } 107 | 108 | #menu-checkbox:checked ~ .intent-page-toc-chapter { 109 | display: block; 110 | } 111 | 112 | .intent-menu-toggler { 113 | margin: 20px; 114 | cursor: pointer; 115 | position: absolute; 116 | top: 20px; 117 | right: 20px; 118 | z-index: 100; 119 | } 120 | 121 | /* 122 | * Menu inspired from Mel Shields CodePen https://codepen.io/shieldsma91/pen/zLpbLX 123 | */ 124 | .intent-menu-toggler span { 125 | display: flex; 126 | height: 2px; 127 | width: 25px; 128 | margin-bottom: 5px; 129 | background: var(--text-color); 130 | border-radius: 3px; 131 | transform-origin: 5px 0px; 132 | transition: transform 0.5s cubic-bezier(0.77,0.2,0.05,1.0), 133 | background 0.5s cubic-bezier(0.77,0.2,0.05,1.0), 134 | opacity 0.55s ease; 135 | } 136 | 137 | .intent-menu-toggler span:first-child { 138 | transform-origin: 0% 0%; 139 | } 140 | 141 | .intent-menu-toggler span:nth-last-child(2) { 142 | transform-origin: 0% 100%; 143 | } 144 | } 145 | 146 | /* 147 | * Table of contents 148 | */ 149 | .intent-page-toc { 150 | flex: 1; 151 | padding-left: 10px; 152 | } 153 | 154 | @media only screen and (max-width: 1200px) { 155 | .intent-page-toc { 156 | text-align: center; 157 | } 158 | 159 | .intent-page-toc-chapter + .intent-page-toc-chapter { 160 | padding-right: 48px; 161 | } 162 | } 163 | 164 | .intent-page-toc h4 { 165 | color: var(--violet-color); 166 | font-variant: small-caps; 167 | } 168 | 169 | .intent-page-toc ul { 170 | list-style: none; 171 | padding-left: 10px; 172 | } 173 | 174 | .intent-page-toc ul li { 175 | padding: 5px 0; 176 | } 177 | 178 | .intent-page-toc ul li a, 179 | .intent-page-toc ul li a:active, 180 | .intent-page-toc ul li a:link 181 | .intent-page-toc ul li a:focus, 182 | .intent-page-toc ul li a:hover { 183 | color: var(--text-color); 184 | font-weight: 300; 185 | text-decoration: none; 186 | border-bottom: none; 187 | } 188 | 189 | .intent-page-toc ul li a:focus, 190 | .intent-page-toc ul li a:hover { 191 | color: var(--violet-color); 192 | text-decoration: none; 193 | border-bottom: none; 194 | } 195 | 196 | /* 197 | * Article and sections 198 | */ 199 | .intent-page-article { 200 | flex: 5; 201 | display: flex; 202 | flex-direction: column; 203 | padding-left: 32px; 204 | padding-right: 32px; 205 | line-height: 1.25; 206 | } 207 | 208 | .intent-page-section { 209 | padding-top: 24px; 210 | } 211 | 212 | .intent-page-section-content { 213 | width: 63%; 214 | display: inline-block; 215 | } 216 | @media only screen and (max-width: 1200px) { 217 | .intent-page-section-content { 218 | display: block; 219 | width: 100%; 220 | } 221 | } 222 | 223 | /** 224 | * Aside - used for tips, tricks, snippets, etc 225 | */ 226 | .intent-page-section-aside { 227 | display: inline-block; 228 | width: 36%; 229 | border: 1px solid #d4d4d4; 230 | overflow: hidden; 231 | background-color: #f8f9f9; 232 | border-radius: 5px; 233 | vertical-align: top; 234 | } 235 | 236 | @media only screen and (max-width: 1200px) { 237 | .intent-page-section-aside { 238 | display: block; 239 | width: 100%; 240 | } 241 | } 242 | 243 | .intent-page-aside-title { 244 | padding: 12px; 245 | } 246 | 247 | .intent-page-aside-title h4 { 248 | padding: 0; 249 | margin: 0; 250 | } 251 | 252 | .intent-page-aside-content { 253 | padding-left: 12px; 254 | } 255 | 256 | /* Disable Hugo built in highlight */ 257 | div.highlight > pre { 258 | background-color: transparent !important; 259 | } 260 | 261 | .intent-example-test { 262 | margin-bottom: 20px; 263 | text-align: right; 264 | } 265 | -------------------------------------------------------------------------------- /site/themes/intent/static/intent.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --violet-color: #7b70b0; 3 | --text-color: #252525; 4 | --fine-color: #a1a1a1; 5 | --link-color: #252525; 6 | --green-color: #5B9940; 7 | } 8 | 9 | html { 10 | font-family: 'Helvetica', sans-serif; 11 | color: var(--text-color); 12 | font-weight: 300; 13 | } 14 | 15 | h1, h2, h3, h4, h5, h6 { 16 | font-weight: 400; 17 | } 18 | 19 | header { 20 | padding: 20px 0; 21 | text-align: center; 22 | } 23 | 24 | header .intent-logo { 25 | text-align: center; 26 | } 27 | 28 | header .intent-logo h1 { 29 | font-weight: 200; 30 | margin-top: 10px; 31 | } 32 | 33 | header .intent-logo a, 34 | header .intent-logo a:active, 35 | header .intent-logo a:hover, 36 | header .intent-logo a:focus, 37 | header .intent-logo a:link { 38 | color: var(--text-color); 39 | text-decoration: none; 40 | border-bottom: none; 41 | } 42 | 43 | 44 | header a.intent-version, 45 | header a.intent-version:active, 46 | header a.intent-version:link 47 | header a.intent-version:focus, 48 | header a.intent-version:hover { 49 | color: var(--fine-color); 50 | font-size: smaller; 51 | font-weight: 300; 52 | text-decoration: none; 53 | border: none; 54 | } 55 | 56 | a, 57 | a:active, 58 | a:link { 59 | color: var(--text-color); 60 | text-decoration: none; 61 | border-bottom: 1px solid var(--text-color); 62 | } 63 | 64 | a:hover { 65 | color: var(--text-color); 66 | border-bottom: 2px solid var(--text-color); 67 | } 68 | 69 | a:focus { 70 | color: var(--text-color); 71 | outline: 0; 72 | border-bottom: 2px solid var(--text-color); 73 | } 74 | 75 | h3 { 76 | margin: 16px 0 8px 0; 77 | } 78 | 79 | code { 80 | padding: 3px; 81 | font-size: larger; 82 | } 83 | 84 | .significant { 85 | font-style: italic; 86 | } 87 | 88 | .intent-page { 89 | display: flex; 90 | } 91 | 92 | @media only screen and (max-width: 1200px) { 93 | .intent-page { 94 | display: flex; 95 | flex-direction: column; 96 | } 97 | } 98 | 99 | #menu-checkbox { 100 | display: none; 101 | } 102 | 103 | @media only screen and (max-width: 1200px) { 104 | .intent-page-toc-chapter { 105 | display: none; 106 | } 107 | 108 | #menu-checkbox:checked ~ .intent-page-toc-chapter { 109 | display: block; 110 | } 111 | 112 | .intent-menu-toggler { 113 | margin: 20px; 114 | cursor: pointer; 115 | position: absolute; 116 | top: 20px; 117 | right: 20px; 118 | z-index: 100; 119 | } 120 | 121 | /* 122 | * Menu inspired from Mel Shields CodePen https://codepen.io/shieldsma91/pen/zLpbLX 123 | */ 124 | .intent-menu-toggler span { 125 | display: flex; 126 | height: 2px; 127 | width: 25px; 128 | margin-bottom: 5px; 129 | background: var(--text-color); 130 | border-radius: 3px; 131 | transform-origin: 5px 0px; 132 | transition: transform 0.5s cubic-bezier(0.77,0.2,0.05,1.0), 133 | background 0.5s cubic-bezier(0.77,0.2,0.05,1.0), 134 | opacity 0.55s ease; 135 | } 136 | 137 | .intent-menu-toggler span:first-child { 138 | transform-origin: 0% 0%; 139 | } 140 | 141 | .intent-menu-toggler span:nth-last-child(2) { 142 | transform-origin: 0% 100%; 143 | } 144 | } 145 | 146 | /* 147 | * Table of contents 148 | */ 149 | .intent-page-toc { 150 | flex: 1; 151 | padding-left: 10px; 152 | } 153 | 154 | @media only screen and (max-width: 1200px) { 155 | .intent-page-toc { 156 | text-align: center; 157 | } 158 | 159 | .intent-page-toc-chapter + .intent-page-toc-chapter { 160 | padding-right: 48px; 161 | } 162 | } 163 | 164 | .intent-page-toc h4 { 165 | color: var(--violet-color); 166 | font-variant: small-caps; 167 | } 168 | 169 | .intent-page-toc ul { 170 | list-style: none; 171 | padding-left: 10px; 172 | } 173 | 174 | .intent-page-toc ul li { 175 | padding: 5px 0; 176 | } 177 | 178 | .intent-page-toc ul li a, 179 | .intent-page-toc ul li a:active, 180 | .intent-page-toc ul li a:link 181 | .intent-page-toc ul li a:focus, 182 | .intent-page-toc ul li a:hover { 183 | color: var(--text-color); 184 | font-weight: 300; 185 | text-decoration: none; 186 | border-bottom: none; 187 | } 188 | 189 | .intent-page-toc ul li a:focus, 190 | .intent-page-toc ul li a:hover { 191 | color: var(--violet-color); 192 | text-decoration: none; 193 | border-bottom: none; 194 | } 195 | 196 | /* 197 | * Article and sections 198 | */ 199 | .intent-page-article { 200 | flex: 5; 201 | display: flex; 202 | flex-direction: column; 203 | padding-left: 32px; 204 | padding-right: 32px; 205 | line-height: 1.25; 206 | } 207 | 208 | .intent-page-section { 209 | padding-top: 24px; 210 | } 211 | 212 | .intent-page-section-content { 213 | width: 63%; 214 | display: inline-block; 215 | } 216 | @media only screen and (max-width: 1200px) { 217 | .intent-page-section-content { 218 | display: block; 219 | width: 100%; 220 | } 221 | } 222 | 223 | /** 224 | * Aside - used for tips, tricks, snippets, etc 225 | */ 226 | .intent-page-section-aside { 227 | display: inline-block; 228 | width: 36%; 229 | border: 1px solid #d4d4d4; 230 | overflow: hidden; 231 | background-color: #f8f9f9; 232 | border-radius: 5px; 233 | vertical-align: top; 234 | } 235 | 236 | @media only screen and (max-width: 1200px) { 237 | .intent-page-section-aside { 238 | display: block; 239 | width: 100%; 240 | } 241 | } 242 | 243 | .intent-page-aside-title { 244 | padding: 12px; 245 | } 246 | 247 | .intent-page-aside-title h4 { 248 | padding: 0; 249 | margin: 0; 250 | } 251 | 252 | .intent-page-aside-content { 253 | padding-left: 12px; 254 | } 255 | 256 | /* Disable Hugo built in highlight */ 257 | div.highlight > pre { 258 | background-color: transparent !important; 259 | } 260 | 261 | .intent-example-test { 262 | margin-bottom: 20px; 263 | text-align: right; 264 | } 265 | -------------------------------------------------------------------------------- /site/content/matchers.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Matchers" 3 | date: 2020-04-09T13:44:39+02:00 4 | draft: false 5 | --- 6 | 7 | Intent's philosophy is to include commonly used matchers for the Scala standard library 8 | (including `Future`, `Option` and `Either`) but not to go overboard. Instead Intent makes is 9 | quite easy to defined your own matchers where needed. 10 | 11 | In our experience, deeply nested or fluent matchers are often hard to discover and the 12 | tooling support (code completion) is limited by having to know each of the namespace 13 | identifiers until you get to the actual match. 14 | 15 | Due to this, Intent strives to keep the matcher namespace as flat as possible altough 16 | some prefixes exists. 17 | 18 | The convention used in the documentation is that the _actual value_ is what is used as 19 | parameter to expect and the _expected value_ value is what is used in the matcher 20 | paramter. 21 | 22 | ```scala 23 | expect( ).( ) 24 | ``` 25 | 26 | > The documentation for each matcher contains a reference to Intent's own unit-tests for that specific matcher to 27 | > serve as additional examples. 28 | 29 | 30 | ## Types 31 | 32 | The following types are currently supported: 33 | 34 | - String 35 | - Boolean 36 | - Int 37 | - Long 38 | - Float 39 | - Double 40 | - Char 41 | - Option 42 | 43 | 44 | ## Prefixes 45 | 46 | All matchers (unless clearly stated) supports the following matcher prefixes. 47 | 48 | ### .not 49 | 50 | Using the `.not` prefix will cause the negated match to be expected. 51 | 52 | ```scala 53 | expect(true).not.toEqual(false) 54 | ``` 55 | 56 | ## Matchers 57 | 58 | ### .toEqual 59 | 60 | Match the _actual_ value to be equal to the _expected_ value. 61 | 62 | ```scala 63 | expect(true).not.toEqual(false) 64 | ``` 65 | 66 | To compare the values, the `==` operator is used behind the scenes. 67 | 68 | The values can also be sequences (`IterableOnce`/`Array`), in which case they must 69 | have the same length and elements on the same position must be equal. 70 | 71 | {{< intent "ToEqualTest.scala" "https://github.com/factor10/intent/blob/master/src/test/scala/intent/matchers/ToEqualTest.scala" >}} 72 | 73 | 74 | ### .toHaveLength 75 | 76 | Match a `Seq`/`List` (in fact, any `IterableOnce`) to have the expected length. 77 | 78 | ```scala 79 | expect(Seq("one")).toHaveLength(1) 80 | ``` 81 | 82 | {{< intent "ToHaveLengthTest.scala" "https://github.com/factor10/intent/blob/master/src/test/scala/intent/matchers/ToHaveLengthTest.scala" >}} 83 | 84 | 85 | ### .toContain 86 | 87 | Match if a given sequence (either `IterableOnce` or `Array`) contains the expected element. 88 | 89 | ```scala 90 | expect(Seq(1, 2, 3)).toContain(2) 91 | ``` 92 | 93 | It can also be used with a `Map` to match if the given Map contains the expected key-value pair. 94 | 95 | ```scala 96 | expect(Map("one" -> 1, "two" -> 2, "three" -> 3)).toContain("two" -> 2) 97 | ``` 98 | 99 | {{< intent "ToContainTest.scala" "https://github.com/factor10/intent/blob/master/src/test/scala/intent/matchers/ToContainTest.scala" >}} 100 | 101 | 102 | ### .toContainAllOf 103 | 104 | Match if a given `Map` contains *all* of the expected key-value pairs. 105 | 106 | ```scala 107 | expect(Map("one" -> 1, "two" -> 2, "three" -> 3)).toContainAllOf("two" -> 2, "one" -> 1) 108 | ``` 109 | 110 | {{< intent "ToContainAllOfTest.scala" "https://github.com/factor10/intent/blob/master/src/test/scala/intent/matchers/ToContainAllOfTest.scala" >}} 111 | 112 | 113 | ### .toCompleteWith 114 | 115 | Match the result of a `Future` to equal the expected value. 116 | 117 | ```scala 118 | expect(Future.successful("foo")).toCompleteWith("foo") 119 | ``` 120 | 121 | {{< intent "ToCompleteWithTest.scala" "https://github.com/factor10/intent/blob/master/src/test/scala/intent/matchers/ToCompleteWithTest.scala" >}} 122 | 123 | 124 | ### .toMatch 125 | 126 | Match the result of a String using a regular expression. 127 | 128 | ```scala 129 | expect(someString).toMatch("^a".r) 130 | ``` 131 | 132 | Note that the regular expression only needs to partially match the actual string, 133 | since we reckon that is the most common use case. Thus, the following test will pass: 134 | 135 | ```scala 136 | expect("foo").toMatch("o+".r) 137 | ``` 138 | 139 | If a complete match is desired, use `^` as prefix and `$` as suffix. For example, 140 | this will fail: 141 | 142 | ```scala 143 | expect("foo").toMatch("^o+$".r) 144 | ``` 145 | 146 | {{< intent "ToMatchTest.scala" "https://github.com/factor10/intent/blob/master/src/test/scala/intent/matchers/ToMatchTest.scala" >}} 147 | 148 | 149 | ### .toThrow 150 | 151 | Test that a piece of code throws an exception, optionally with a particular message. 152 | 153 | ```scala 154 | def div(n: Int, d: Int) = 155 | require(d != 0, "Division by zero") 156 | n / d 157 | expect(div(5, 0)).toThrow[IllegalArgumentException]("requirement failed: Division by zero") 158 | ``` 159 | 160 | This matcher can be used in three different ways: 161 | 162 | 1. With no expected message: 163 | 164 | ```scala 165 | expect(div(5, 0)).toThrow[IllegalArgumentException]() 166 | ``` 167 | 168 | 2. With an exact expected message: 169 | 170 | ```scala 171 | expect(div(5, 0)).toThrow[IllegalArgumentException]("requirement failed: Division by zero") 172 | ``` 173 | 174 | 3. With a regular expression, which is applied as a partial match: 175 | 176 | ```scala 177 | expect(div(5, 0)).toThrow[IllegalArgumentException]("failed.*zero".r) 178 | ``` 179 | 180 | Note that since the argument to `expect` is a block, testing of a more complex piece of 181 | potentially-throwing code can be written as follows: 182 | 183 | ```scala 184 | expect: 185 | val numerator = 5 186 | val denominator = 0 187 | div(numerator, denominator) 188 | .toThrow[IllegalArgumentException]() 189 | ``` 190 | 191 | `toThrow` is satisified when the actual exception is of the same type as or a sub type of the 192 | expected exception. Thus, the following test passes: 193 | 194 | ```scala 195 | expect(div(5, 0)).toThrow[RuntimeException]() 196 | ``` 197 | 198 | {{< intent "ToThrowTest.scala" "https://github.com/factor10/intent/blob/master/src/test/scala/intent/matchers/ToThrowTest.scala" >}} 199 | 200 | -------------------------------------------------------------------------------- /src/main/scala/intent/core/expect.scala: -------------------------------------------------------------------------------- 1 | package intent.core 2 | 3 | import scala.concurrent.{ExecutionContext, Future} 4 | import scala.util.{Success, Failure} 5 | import scala.collection.IterableOnce 6 | import scala.collection.mutable.ListBuffer 7 | import scala.language.implicitConversions 8 | import scala.util.matching.Regex 9 | import scala.reflect.ClassTag 10 | 11 | import intent.core.{Expectation, ExpectationResult, TestPassed, TestFailed, PositionDescription, MapLike} 12 | import intent.macros.Position 13 | import intent.util.DelayedFuture 14 | import intent.core.expectations._ 15 | 16 | /** 17 | * Defines cutoff limit for list comparisons (equality as well as contains), in order to make it 18 | * possible to use `toEqual` and `toContain` with infinite lists. 19 | * 20 | * @param maxItems determines how many items of the list to check before giving up 21 | * @param printItems the number of items to print in case of a "cutoff abort" 22 | */ 23 | case class ListCutoff(maxItems: Int = 1000, printItems: Int = 5) 24 | 25 | class CompoundExpectation(inner: Seq[Expectation])(using ec: ExecutionContext) extends Expectation: 26 | def evaluate(): Future[ExpectationResult] = 27 | val innerFutures = inner.map(_.evaluate()) 28 | Future.sequence(innerFutures).map { results => 29 | // any failure => failure 30 | ??? 31 | } 32 | 33 | class Expect[T](blk: => T, position: Position, negated: Boolean = false): 34 | import PositionDescription._ 35 | 36 | def evaluate(): T = blk 37 | def isNegated: Boolean = negated 38 | def negate(): Expect[T] = new Expect(blk, position, !negated) 39 | 40 | def fail(desc: String): ExpectationResult = TestFailed(position.contextualize(desc), None) 41 | def fail(desc: String, t: Throwable): ExpectationResult = TestFailed(position.contextualize(desc), Some(t)) 42 | def pass: ExpectationResult = TestPassed() 43 | 44 | trait ExpectGivens: 45 | 46 | given defaultListCutoff as ListCutoff = ListCutoff() 47 | 48 | def [T](expect: Expect[T]) not: Expect[T] = expect.negate() 49 | 50 | def [T](expect: Expect[T]) toEqual (expected: T)(using eqq: Eq[T], fmt: Formatter[T]): Expectation = 51 | new EqualExpectation(expect, expected) 52 | 53 | // toMatch is partial 54 | def [T](expect: Expect[String]) toMatch (re: Regex)(using fmt: Formatter[String]): Expectation = 55 | new MatchExpectation(expect, re) 56 | 57 | def [T](expect: Expect[Future[T]]) toCompleteWith (expected: T) 58 | (using 59 | eqq: Eq[T], 60 | fmt: Formatter[T], 61 | errFmt: Formatter[Throwable], 62 | ec: ExecutionContext, 63 | timeout: TestTimeout 64 | ): Expectation = 65 | new ToCompleteWithExpectation(expect, expected) 66 | 67 | // We use ClassTag here to avoid "double definition error" wrt Expect[IterableOnce[T]] 68 | def [T : ClassTag](expect: Expect[Array[T]]) toContain (expected: T) 69 | (using 70 | eqq: Eq[T], 71 | fmt: Formatter[T], 72 | cutoff: ListCutoff 73 | ): Expectation = 74 | new ArrayContainExpectation(expect, expected) 75 | 76 | def [T](expect: Expect[IterableOnce[T]]) toContain (expected: T) 77 | (using 78 | eqq: Eq[T], 79 | fmt: Formatter[T], 80 | cutoff: ListCutoff 81 | ): Expectation = 82 | new IterableContainExpectation(expect, expected) 83 | 84 | /** 85 | * Expect that a key-value tuple exists in the given map 86 | */ 87 | def [K, V](expect: Expect[MapLike[K, V]]) toContain (expected: (K, V)) 88 | (using 89 | eqq: Eq[V], 90 | keyFmt: Formatter[K], 91 | valueFmt: Formatter[V] 92 | ): Expectation = new MapContainExpectation(expect, Seq(expected)) 93 | 94 | /** 95 | * Expect that multiple key-value tuple exists in the given map 96 | */ 97 | def [K, V](expect: Expect[MapLike[K, V]]) toContainAllOf (expected: (K, V)*) 98 | (using 99 | eqq: Eq[V], 100 | keyFmt: Formatter[K], 101 | valueFmt: Formatter[V] 102 | ): Expectation = new MapContainExpectation(expect, expected) 103 | 104 | // Note: Not using IterableOnce here as it matches Option and we don't want that. 105 | def [T](expect: Expect[Iterable[T]]) toEqual (expected: Iterable[T]) 106 | (using 107 | eqq: Eq[T], 108 | fmt: Formatter[T] 109 | ): Expectation = 110 | new IterableEqualExpectation(expect, expected) 111 | 112 | // We use ClassTag here to avoid "double definition error" wrt Expect[Iterable[T]] 113 | def [T : ClassTag](expect: Expect[Array[T]]) toEqual (expected: Iterable[T]) 114 | (using 115 | eqq: Eq[T], 116 | fmt: Formatter[T] 117 | ): Expectation = 118 | new ArrayEqualExpectation(expect, expected) 119 | 120 | /** 121 | * (1, 2, 3) toHaveLength 3 122 | */ 123 | def [T](expect: Expect[IterableOnce[T]]) toHaveLength (expected: Int)(using ec: ExecutionContext): Expectation = 124 | new LengthExpectation(expect, expected) 125 | 126 | // toThrow with only exception type 127 | def [TEx : ClassTag](expect: Expect[_]) toThrow ()(using fmt: Formatter[String]): Expectation = 128 | new ThrowExpectation[TEx](expect, AnyExpectedMessage) 129 | 130 | // toThrow with exception type + message (string, so full match) 131 | def [TEx : ClassTag](expect: Expect[_]) toThrow (expectedMessage: String)(using fmt: Formatter[String]): Expectation = 132 | new ThrowExpectation[TEx](expect, ExactExpectedMessage(expectedMessage)) 133 | 134 | // toThrow with exception type + regexp (partial match, like toMatch) 135 | def [TEx : ClassTag](expect: Expect[_]) toThrow (re: Regex)(using fmt: Formatter[String]): Expectation = 136 | new ThrowExpectation[TEx](expect, RegexExpectedMessage(re)) 137 | 138 | // TODO: 139 | // - toContain i lista (massa varianter, IterableOnce-ish) 140 | // - toContain i Map (immutable + mutable) 141 | // - toContainKey+toContainValue i Map 142 | // - i Jasmine: expect(x).toEqual(jasmine.objectContaining({ foo: jasmine.arrayContaining("bar") })) 143 | // - toContain(value | KeyConstraint | ValueConstraint) 144 | // - expect(myMap).toContain(key("foo")) 145 | // - expect(myMap) toContain "foo" -> 42 146 | // - expect(myMap) toContain(value(42)) 147 | // - expect(myMap).to.contain.key(42) 148 | -------------------------------------------------------------------------------- /docs/index.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Intent - A test framework for Dotty on Intent 5 | https://factor10.github.io/intent/ 6 | Recent content in Intent - A test framework for Dotty on Intent 7 | Hugo -- gohugo.io 8 | en-us 9 | Thu, 09 Apr 2020 13:44:39 +0200 10 | 11 | 12 | 13 | 14 | 15 | Asynchronous tests 16 | https://factor10.github.io/intent/types-of-tests/asynchronous/ 17 | Thu, 09 Apr 2020 13:44:39 +0200 18 | 19 | https://factor10.github.io/intent/types-of-tests/asynchronous/ 20 | Asynchronous tests Intent supports stateful tests where the state is produced asynchronously. An example: 21 | class AsyncStatefulTest extends TestSuite with AsyncState[AsyncStatefulState]: &#34;an empty cart&#34; using Cart() to : &#34;with two items&#34; usingAsync (_.add(CartItem(&#34;beach-chair&#34;, 2))) to : &#34;and another three items&#34; usingAsync (_.add(CartItem(&#34;sunscreen&#34;, 3))) to : &#34;calculates total price&#34; in : cart =&gt; expect(cart.totalPrice).toEqual(275.0d) case class CartItem(artNo: String, qty: Int) case class PricedCartItem(item: CartItem, price: Double): def totalPrice = item.qty * price case class Cart(items: Seq[PricedCartItem] = Seq. 22 | 23 | 24 | 25 | Customization 26 | https://factor10.github.io/intent/customization/ 27 | Thu, 09 Apr 2020 13:44:39 +0200 28 | 29 | https://factor10.github.io/intent/customization/ 30 | Manually fail or succeed a test Two convenience methods exists where you can manually provide the the test expectation: 31 | fail(&quot;Reason for failure...&quot;) to fail a test success() to pass a test Asyncness If you need to await the result of a Future before using a matcher, you can use whenComplete: 32 | whenComplete(Future.successful(Seq(&#34;foo&#34;, &#34;bar&#34;))): actual =&gt; expect(actual).toContain(&#34;foo&#34;) This allows for more complex testing compared to when using toCompleteWith. 33 | whenComplete can be used regardless of the suite type, i. 34 | 35 | 36 | 37 | Matchers 38 | https://factor10.github.io/intent/matchers/ 39 | Thu, 09 Apr 2020 13:44:39 +0200 40 | 41 | https://factor10.github.io/intent/matchers/ 42 | Intent&rsquo;s philosophy is to include commonly used matchers for the Scala standard library (including Future, Option and Either) but not to go overboard. Instead Intent makes is quite easy to defined your own matchers where needed. 43 | In our experience, deeply nested or fluent matchers are often hard to discover and the tooling support (code completion) is limited by having to know each of the namespace identifiers until you get to the actual match. 44 | 45 | 46 | 47 | Stateful tests 48 | https://factor10.github.io/intent/types-of-tests/stateful/ 49 | Thu, 09 Apr 2020 13:44:39 +0200 50 | 51 | https://factor10.github.io/intent/types-of-tests/stateful/ 52 | Stateful tests Not all tests can be implemented without setting the scene, still many test frameworks only focus on a expressive way to assert expectations. For Intent state management is front and center. 53 | Lets go straight to the code: 54 | class StatefulTest extends TestSuite with State[Cart]: &#34;an empty cart&#34; using Cart() to : &#34;with two items&#34; using (_.add(CartItem(&#34;beach-chair&#34;, 2))) to : &#34;and another three items&#34; using (_.add(CartItem(&#34;sunscreen&#34;, 3))) to : &#34;contains 5 items&#34; in : cart =&gt; expect(cart. 55 | 56 | 57 | 58 | Table-driven tests 59 | https://factor10.github.io/intent/types-of-tests/stateless/ 60 | Thu, 09 Apr 2020 13:44:39 +0200 61 | 62 | https://factor10.github.io/intent/types-of-tests/stateless/ 63 | Table-driven tests A stateless test suite extends the Statesless suite. Contexts in this style serve no other purpose than grouping tests into logical units. 64 | Consider the following example: 65 | import intent.{Stateless, TestSuite} class CalculatorTest extends TestSuite with Stateless: &#34;A calculator&#34; : &#34;can add&#34; : &#34;plain numbers&#34; in expect(Calculator().add(2, 4)).toEqual(6) &#34;complex numbers&#34; in: val a = Complex(2, 3) val b = Complex(3, 4) expect(Calculator().add(a, b)).toEqual(Complex(5, 7)) &#34;can multiply&#34; : &#34;plain numbers&#34; in expect(Calculator(). 66 | 67 | 68 | 69 | Table-driven tests 70 | https://factor10.github.io/intent/types-of-tests/table-driven/ 71 | Thu, 09 Apr 2020 13:44:39 +0200 72 | 73 | https://factor10.github.io/intent/types-of-tests/table-driven/ 74 | Table-driven tests Table-driven tests allow for a declarative approach to writing tests, and is useful to test many different variations of some feature with as little boilerplate as possible. 75 | For example, consider the following test suite that tests a Fibonacci function: 76 | class FibonacciTest extends TestSuite with State[TableState]: &#34;The Fibonacci function&#34; usingTable (examples) to : &#34;works&#34; in : example =&gt; expect(F(example.n)).toEqual(example.expected) def examples = Seq( FibonacciExample(0, 0), FibonacciExample(1, 1), FibonacciExample(2, 1), FibonacciExample(3, 2), FibonacciExample(12, 144) ) def F(n: Int): Int = . 77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /docs/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 404 Page not found · Intent 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |
15 | 22 |
23 | 24 | 29 | 30 |
31 | 32 | 33 | 34 |

Introduction

35 | 54 | 55 | 56 | 57 |

Types of tests

58 | 77 | 78 | 79 | 80 |

Matchers

81 | 112 | 113 | 114 | 115 |

Customization

116 | 143 | 144 | 145 |
146 |
147 | 148 | 149 |
150 |
151 |
152 |

Page not found

153 |
154 |
155 |
156 |
157 | 158 | 159 | 160 | 161 | 162 | -------------------------------------------------------------------------------- /src/main/scala/intent/sbt/Runner.scala: -------------------------------------------------------------------------------- 1 | package intent.sbt 2 | 3 | import intent.core.{IntentStructure, TestPassed, TestFailed, TestError, TestIgnored, Subscriber, TestCaseResult} 4 | import intent.runner.TestSuiteRunner 5 | import sbt.testing.{Framework => SFramework, _ } 6 | import scala.concurrent.duration._ 7 | import scala.language.implicitConversions 8 | import java.io.{PrintWriter, StringWriter} 9 | 10 | class Framework extends SFramework: 11 | def name(): String = "intent" 12 | def fingerprints(): Array[Fingerprint] = Array(IntentFingerprint) 13 | def runner(args: Array[String], remoteArgs: Array[String], testClassLoader: ClassLoader): Runner = 14 | new SbtRunner(args, remoteArgs, testClassLoader) 15 | 16 | private def printErrorWithPrefix(t: Throwable, linePrefix: String): String = 17 | val sw = StringWriter() 18 | t.printStackTrace(new PrintWriter(sw)) 19 | sw.toString.split("\n").map(line => linePrefix + line).mkString("\n") 20 | 21 | /** 22 | * Defines how to find the Intent's test classes 23 | */ 24 | object IntentFingerprint extends SubclassFingerprint: 25 | // Disable the usage of modules (singleton objects) - this makes discovery faster 26 | val isModule = false 27 | 28 | // Constructor parameters will not be injected and will only cause confusion - disable. 29 | def requireNoArgConstructor(): Boolean = true 30 | 31 | // All tests needs to inherit from this type 32 | def superclassName(): String = "intent.core.TestSuite" 33 | 34 | /** 35 | * A single run of a single test (controlled by SBT) 36 | */ 37 | class SbtRunner( 38 | val args: Array[String], 39 | val remoteArgs: Array[String], 40 | classLoader: ClassLoader 41 | ) extends Runner: 42 | 43 | def done(): String = 44 | // This is called when test is done, and after that the test task myst not be called 45 | // Whatever is returned here is printed in the SBT log just before the summary 46 | "" 47 | 48 | /** 49 | * Called by SBT with all the classes that match our fingerprint. 50 | * 51 | */ 52 | def tasks(potentialTasks: Array[TaskDef]): Array[Task] = 53 | case class Suite(td: TaskDef, structure: IntentStructure) 54 | 55 | val runner = new TestSuiteRunner(classLoader) 56 | var focusMode = false 57 | var potentialSuites: Array[Suite] = potentialTasks 58 | .map(td => { 59 | runner.evaluateSuite(td.fullyQualifiedName) match { 60 | case Left(ex) => 61 | // At this point there is no loggers, so just throw and abort execution. 62 | // Errors here might occur if the class cannot be found, or if an error 63 | // happen while instantiating 64 | throw ex 65 | case Right(suite) => 66 | if suite.isFocused then focusMode = true 67 | Suite(td, suite) 68 | } 69 | }) 70 | 71 | if focusMode then 72 | potentialSuites = potentialSuites.filter(_.structure.isFocused) 73 | 74 | potentialSuites 75 | .map(s => SbtTask(s.td, s.structure, runner, focusMode)) 76 | 77 | class SbtTask(td: TaskDef, suit: IntentStructure, runner: TestSuiteRunner, focusMode: Boolean) extends Task: 78 | import scala.concurrent.{Await, Future} 79 | import scala.concurrent.duration._ 80 | import scala.concurrent.ExecutionContext 81 | import scala.util.{Success, Failure} 82 | 83 | override def taskDef = td 84 | 85 | // Tagging tests or suites are currently not supported 86 | override def tags(): Array[String] = Array.empty 87 | 88 | override def execute(handler: EventHandler, loggers: Array[Logger]): Array[Task] = 89 | implicit val ec = ExecutionContext.global 90 | // Hmm, do we need really to block here? Does SBT come with something included to be async 91 | Await.result(executeSuite(handler, loggers), Duration.Inf) 92 | 93 | private def executeSuite(handler: EventHandler, loggers: Array[Logger])(using ec: ExecutionContext): Future[Array[Task]] = 94 | object lock 95 | val eventSubscriber = new Subscriber[TestCaseResult]: 96 | override def onNext(event: TestCaseResult): Unit = 97 | val sbtEvent = event.expectationResult match 98 | case success: TestPassed => SuccessfulEvent(event.duration.toMillis, taskDef.fullyQualifiedName, event.qualifiedName, taskDef.fingerprint, focusMode) 99 | case failure: TestFailed => FailedEvent(event.duration.toMillis, taskDef.fullyQualifiedName, event.qualifiedName, taskDef.fingerprint, focusMode, failure.output, failure.ex) 100 | case error: TestError => ErrorEvent(event.duration.toMillis, taskDef.fullyQualifiedName, event.qualifiedName, taskDef.fingerprint, focusMode, error.context, error.ex) 101 | case ignored: TestIgnored => IgnoredEvent(taskDef.fullyQualifiedName, event.qualifiedName, taskDef.fingerprint, focusMode) 102 | lock.synchronized: 103 | handler.handle(sbtEvent) 104 | sbtEvent.log(loggers, event.duration.toMillis) 105 | 106 | runner.runSuite(td.fullyQualifiedName, Some(eventSubscriber)).map(_ => Array.empty) 107 | 108 | trait LoggedEvent(color: String, prefix: String, suiteName: String, testNames: Seq[String]): 109 | val fullyQualifiedTestName: String = suiteName + " >> " + testNames.mkString(" >> ") 110 | 111 | def log(loggers: Array[Logger], executionTime: Long): Unit = loggers.foreach(_.info(color + s"[${prefix}] ${fullyQualifiedTestName} (${executionTime} ms)")) 112 | 113 | case class SuccessfulEvent(duration: Long, suiteName: String, testNames: Seq[String], fingerprint: Fingerprint, focusMode: Boolean) 114 | extends Event with LoggedEvent(Console.GREEN, "PASSED", suiteName, testNames): 115 | 116 | override def fullyQualifiedName = suiteName 117 | override def status = sbt.testing.Status.Success 118 | override def selector(): sbt.testing.Selector = new NestedTestSelector(suiteName, testNames.mkString(" >> ")) 119 | override def throwable(): sbt.testing.OptionalThrowable = sbt.testing.OptionalThrowable() 120 | 121 | case class FailedEvent(duration: Long, suiteName: String, testNames: Seq[String], fingerprint: Fingerprint, focusMode: Boolean, assertionMessage: String, err: Option[Throwable]) 122 | extends Event with LoggedEvent(Console.RED, "FAILED", suiteName, testNames): 123 | val color = Console.RED // Why are not these inherited form LoggedEvent? 124 | val prefix = "FAILED" 125 | 126 | override def fullyQualifiedName = suiteName 127 | override def status = sbt.testing.Status.Failure 128 | override def selector(): sbt.testing.Selector = new NestedTestSelector(suiteName, testNames.mkString(" >> ")) 129 | override def throwable(): sbt.testing.OptionalThrowable = err 130 | 131 | override def log(loggers: Array[Logger], executionTime: Long): Unit = 132 | val error = err.map(e => "\n\n" + printErrorWithPrefix(e, s"\t${color}")).getOrElse("") 133 | loggers.foreach(_.info(color + s"[${prefix}] ${fullyQualifiedTestName} (${executionTime} ms) \n\t${color}${assertionMessage}${error}")) 134 | 135 | case class ErrorEvent(duration: Long, suiteName: String, testNames: Seq[String], fingerprint: Fingerprint, focusMode: Boolean, errContext: String, err: Option[Throwable]) 136 | extends Event with LoggedEvent(Console.RED, "ERROR", suiteName, testNames): 137 | val color = Console.RED // Why are not these inherited form LoggedEvent? 138 | val prefix = "ERROR" 139 | 140 | override def fullyQualifiedName = suiteName 141 | override def status = sbt.testing.Status.Failure 142 | override def selector(): sbt.testing.Selector = new NestedTestSelector(suiteName, testNames.mkString(" >> ")) 143 | override def throwable(): sbt.testing.OptionalThrowable = err 144 | override def log(loggers: Array[Logger], executionTime: Long): Unit = 145 | val error = err.map(e => "\n\n" + printErrorWithPrefix(e, s"\t${color}")).getOrElse("") 146 | loggers.foreach(_.info(color + s"[${prefix}] ${fullyQualifiedTestName} (${executionTime} ms) \n\t${color}${errContext}${error}")) 147 | 148 | case class IgnoredEvent(suiteName: String, testNames: Seq[String], fingerprint: Fingerprint, focusMode: Boolean) 149 | extends Event with LoggedEvent(Console.YELLOW, "IGNORED", suiteName, testNames): 150 | 151 | override def duration = 0L 152 | override def fullyQualifiedName = suiteName 153 | override def status = sbt.testing.Status.Ignored 154 | override def selector(): sbt.testing.Selector = new NestedTestSelector(suiteName, testNames.mkString(" >> ")) 155 | override def throwable(): sbt.testing.OptionalThrowable = sbt.testing.OptionalThrowable() 156 | override def log(loggers: Array[Logger], executionTime: Long): Unit = 157 | if (!focusMode) then super.log(loggers, executionTime) 158 | 159 | implicit def option2ot(ot: Option[Throwable]): OptionalThrowable = 160 | ot match 161 | case Some(e) => sbt.testing.OptionalThrowable(e) 162 | case None => sbt.testing.OptionalThrowable() 163 | -------------------------------------------------------------------------------- /src/test/scala/intent/runner/FocusedTest.scala: -------------------------------------------------------------------------------- 1 | package intent.runner 2 | 3 | import intent.{TestSuite, State, Stateless, AsyncState} 4 | import intent.core.{Expectation, ExpectationResult, TestError, TestFailed, Subscriber, TestCaseResult, IntentStructure} 5 | import intent.runner.{TestSuiteRunner, TestSuiteError, TestSuiteResult} 6 | import intent.testdata._ 7 | import intent.helpers.TestSuiteRunnerTester 8 | 9 | import scala.concurrent.{ExecutionContext, Future} 10 | 11 | class FocusedTest extends TestSuite with State[FocusedTestCase]: 12 | "FocusedTest" usingTable (focusedSuites) to: 13 | "should be focused" in: 14 | state => 15 | expect(state.evaluate().isFocused).toEqual(state.focused) 16 | 17 | "report that correct number of tests were run" in: 18 | state => 19 | whenComplete(state.runAll()): 20 | possible => possible match 21 | case Left(_) => fail("Unexpected Left") 22 | case Right(result) => expect(result.successful).toEqual(state.expectedSuccessful) 23 | 24 | "report that correct number test were ignored" in: 25 | state => 26 | whenComplete(state.runAll()): 27 | possible => possible match 28 | case Left(_) => fail("Unexpected Left") 29 | case Right(result) => expect(result.ignored).toEqual(state.expectedIgnored) 30 | 31 | "no tests should be failed" in: 32 | state => 33 | whenComplete(state.runAll()): 34 | possible => possible match 35 | case Left(_) => fail("Unexpected Left") 36 | case Right(result) => expect(result.failed).toEqual(0) 37 | 38 | def focusedSuites = Seq( 39 | FocusedTestCase("intent.runner.NestedFocusedStatelessTestSuite", expectedSuccessful = 4, expectedIgnored = 2, focused = true), 40 | FocusedTestCase("intent.runner.MidBranchFocusedStatelessTestSuite", expectedSuccessful = 3, expectedIgnored = 2, focused = true), 41 | FocusedTestCase("intent.runner.NestedFocusedStatefulTestSuite", expectedSuccessful = 3, expectedIgnored = 2, focused = true), 42 | FocusedTestCase("intent.runner.MidBranchFocusedStatefulTestSuite", expectedSuccessful = 3, expectedIgnored = 2, focused = true), 43 | FocusedTestCase("intent.runner.FocusedStatelessTestSuite", expectedSuccessful = 2, expectedIgnored = 1, focused = true), 44 | FocusedTestCase("intent.runner.FocusedStatefulTestSuite", expectedSuccessful = 2, expectedIgnored = 1, focused = true), 45 | FocusedTestCase("intent.runner.FocusedAsyncStatefulTestSuite", expectedSuccessful = 4, expectedIgnored = 1, focused = true), 46 | FocusedTestCase("intent.runner.FocusedTableDrivenTestSuite", expectedSuccessful = 4, expectedIgnored = 1, focused = true), 47 | FocusedTestCase("intent.runner.IgnoredStatelessTestSuite", expectedSuccessful = 1, expectedIgnored = 5, focused = false), 48 | FocusedTestCase("intent.runner.IgnoredTableDrivenTestSuite", expectedSuccessful = 2, expectedIgnored = 3, focused = false), 49 | FocusedTestCase("intent.runner.IgnoredStatefulTestSuite", expectedSuccessful = 1, expectedIgnored = 3, focused = false), 50 | FocusedTestCase("intent.runner.IgnoredAsyncStatefulTestSuite", expectedSuccessful = 2, expectedIgnored = 2, focused = false), 51 | ) 52 | 53 | case class FocusedTestCase( 54 | suiteClassName: String = null, 55 | expectedSuccessful:Int = 0, 56 | expectedIgnored:Int = 0, 57 | focused: Boolean = false)(using ec: ExecutionContext) extends TestSuiteRunnerTester 58 | 59 | class NestedFocusedStatelessTestSuite extends Stateless: 60 | "some suite" focused: 61 | "nested suite": 62 | "should be run" in success() 63 | "should also be run" in success() 64 | 65 | "another suite": 66 | "should *not* be run" in fail("should not happen") 67 | 68 | "a third suite": 69 | "should include single focus" focus success() 70 | 71 | "should also *not* be run" in fail("should not happen") 72 | "should be run" focus success() 73 | 74 | class MidBranchFocusedStatelessTestSuite extends Stateless: 75 | "some suite": 76 | "another suite": 77 | "should *not* be run A" in fail("should not happen") 78 | "should also *not* be run B" in fail("should not happen") 79 | 80 | "nested suite" focused: 81 | "should be run C" in success() 82 | "should also be run D" in success() 83 | 84 | "a third suite": 85 | "should include single focus" focus success() 86 | 87 | class NestedFocusedStatefulTestSuite extends State[Unit]: 88 | "focused suite" using (()) focused: 89 | "nested suite" using (()) to: 90 | "should be run 1" in: 91 | _ => success() 92 | 93 | "should also be run 2" in: 94 | _ => success() 95 | 96 | "another suite" using (()) to: 97 | "should *not* be run 3" in: 98 | _ => fail("should not happen") 99 | 100 | "should also *not* be run 4" in: 101 | _ => fail("should not happen") 102 | 103 | "a third suite" using (()) to: 104 | "should include single focus" focus: 105 | _ => success() 106 | 107 | class MidBranchFocusedStatefulTestSuite extends State[Unit]: 108 | "some suite" using (()) to: 109 | "another suite" using (()) to: 110 | "should *not* be run" in: 111 | _ => fail("should not happen") 112 | 113 | "should also *not* be run 4" in: 114 | _ => fail("should not happen") 115 | 116 | "nested suite" using (()) focused: 117 | "should be run" in: 118 | _ => success() 119 | 120 | "should also be run" in: 121 | _ => success() 122 | 123 | "a third suite" using (()) to: 124 | "should include single focus" focus: 125 | _ => success() 126 | 127 | class FocusedTableDrivenTestSuite extends State[Unit]: 128 | "top level" using (()) to: 129 | "nested level" using (()) to: 130 | "should not be run" in: 131 | _ => fail("should not happen") 132 | "should be run" focus: 133 | _ => success() 134 | 135 | "another suite" using (()) to: 136 | "that use a table" usingTable(tableTests) focused: 137 | "should be run" in: 138 | _ => success() 139 | 140 | def tableTests = Seq((), (), ()) 141 | 142 | class FocusedStatelessTestSuite extends Stateless: 143 | "should not be run" in fail("Test is not expected to run!") 144 | "should be run" focus success() 145 | "should also be run" focus success() 146 | 147 | class FocusedStatefulTestSuite extends State[Unit]: 148 | "with state" using (()) to: 149 | "should not be run" in: 150 | _ => fail("Test is not expected to run!") 151 | "should be run" focus: 152 | _ => success() 153 | "should also be run" focus: 154 | _ => success() 155 | 156 | class FocusedAsyncStatefulTestSuite extends AsyncState[Unit]: 157 | "with state" usingAsync (Future.successful(())) to: 158 | "should not be run" in: 159 | _ => fail("Test is not expected to run!") 160 | "should be run" focus: 161 | _ => success() 162 | "should also be run" focus: 163 | _ => success() 164 | 165 | "sibling" usingAsync (Future.successful(())) focused: 166 | "sibling child" usingAsync (Future.successful(())) to: 167 | "should be run" in: 168 | _ => success() 169 | "should be run" in: 170 | _ => success() 171 | 172 | class IgnoredStatelessTestSuite extends Stateless: 173 | "top": 174 | "child" ignored: 175 | "should not be run" in fail("should not happen") 176 | "should also not be run" in fail("should not happen") 177 | "should ignore focus" focus fail("should not happen") 178 | "focus has less prio" focused: 179 | "should not be run" in fail("should not happen") 180 | "should also not be run" in fail("should not happen") 181 | "should be run" in success() 182 | 183 | class IgnoredTableDrivenTestSuite extends State[Unit]: 184 | "top level" using (()) to: 185 | "nested level" using (()) to: 186 | "should be run" in: 187 | _ => success() 188 | 189 | "another suite" using (()) to: 190 | "that use a table" usingTable(tableTests) ignored: 191 | "should *not* be run" in: 192 | _ => fail("should not happen") 193 | "should be run" in: 194 | _ => success() 195 | 196 | def tableTests = Seq((), (), ()) 197 | 198 | class IgnoredStatefulTestSuite extends State[Unit]: 199 | "some suite" using (()) ignored: 200 | "nested suite" using (()) to: 201 | "should not be run" in: 202 | _ => fail("should not happen") 203 | "nested focus has lower prio" using (()) focused: 204 | "should not be run" in: 205 | _ => fail("should not happen") 206 | 207 | "should also not be run" in: 208 | _ => fail("should not happen") 209 | 210 | "another suite" using (()) to: 211 | "should be run" in: 212 | _ => success() 213 | 214 | class IgnoredAsyncStatefulTestSuite extends AsyncState[Unit]: 215 | "with state" usingAsync (Future.successful(())) ignored: 216 | "should not be run" in: 217 | _ => fail("Test is not expected to run!") 218 | "should be run" focus: 219 | _ => fail("Test is not expected to run!") 220 | 221 | "sibling" usingAsync (Future.successful(())) to: 222 | "sibling child" usingAsync (Future.successful(())) to: 223 | "should be run" in: 224 | _ => success() 225 | "should be run" in: 226 | _ => success() 227 | -------------------------------------------------------------------------------- /src/test/scala/intent/runner/TestSuiteRunnerTest.scala: -------------------------------------------------------------------------------- 1 | package intent.runner 2 | 3 | import intent.{TestSuite, State, Stateless, AsyncState} 4 | import intent.core.{Expectation, ExpectationResult, TestError, TestFailed, Subscriber, TestCaseResult, IntentStructure} 5 | import intent.runner.{TestSuiteRunner, TestSuiteError, TestSuiteResult} 6 | import intent.testdata._ 7 | import intent.helpers.TestSuiteRunnerTester 8 | 9 | import scala.concurrent.{ExecutionContext, Future} 10 | 11 | class TestSuiteRunnerTest extends TestSuite with State[TestSuiteTestCase]: 12 | "TestSuiteRunner" using TestSuiteTestCase() to: 13 | 14 | "running a stateful suite without context" using (_.noContextTestSuite) to: 15 | "has an error event" in: 16 | expectErrorMatching("^Top-level test cases".r) 17 | 18 | "running a suite that fails in setup" using (_.setupFailureTestSuite) to: 19 | "reports that 1 test was run" in: 20 | state => 21 | whenComplete(state.runAll()): 22 | case Left(_) => fail("unexpected Left") 23 | case Right(result) => expect(result.total).toEqual(1) 24 | 25 | "reports that 1 test failed" in: 26 | state => 27 | whenComplete(state.runAll()): 28 | case Left(_) => fail("unexpected Left") 29 | case Right(result) => expect(result.failed).toEqual(1) 30 | 31 | "has a failure event with the exception" in: 32 | state => 33 | whenComplete(state.runWithEventSubscriber()): 34 | case Left(_) => fail("unexpected Left") 35 | case Right(_) => 36 | val maybeEx = state.receivedEvents().collectFirst { case TestCaseResult(_, _, TestFailed(_, Some(ex))) => ex } 37 | maybeEx match 38 | case Some(ex) => expect(ex.getMessage).toEqual("intentional failure") 39 | case None => fail("unexpected None") 40 | 41 | "running an async stateful suite that fails in setup" using (_.setupFailureAsyncTestSuite) to: 42 | "collects exceptions for all the failure variants" in: 43 | state => 44 | whenComplete(state.runWithEventSubscriber()): 45 | case Left(_) => fail("unexpected Left") 46 | case Right(_) => 47 | val exceptions = state.receivedEvents().collect { case TestCaseResult(_, _, TestFailed(_, Some(ex))) => ex } 48 | // TODO: We need a better matcher here... Or multiple test cases! 49 | val combined = exceptions.map(_.getMessage).mkString("|") 50 | expect(combined).toEqual("intentional failure|intentional failure|intentional failure") 51 | 52 | "describes all the failure variants" in: 53 | state => 54 | whenComplete(state.runWithEventSubscriber()): 55 | case Left(_) => fail("unexpected Left") 56 | case Right(_) => 57 | val messages = state.receivedEvents().collect { case TestCaseResult(_, _, TestFailed(msg, _)) => msg } 58 | // TODO: We need a better matcher here... Or multiple test cases! 59 | val combined = messages.mkString("|") 60 | expect(combined).toMatch("^The state setup".r) // TODO: this doesn't test all three 61 | 62 | "running an async stateful suite without context" using (_.noContextAsyncTestSuite) to: 63 | "has an error event" in: 64 | expectErrorMatching("^Top-level test cases".r) 65 | 66 | "running an empty suite" using (_.emptyTestSuite) to: 67 | "report that zero tests were run" in: 68 | state => 69 | whenComplete(state.runAll()): 70 | possible => possible match 71 | case Left(_) => fail("Unexpected Left") 72 | case Right(result) => expect(result.total).toEqual(0) // TODO: Match on case class or individual fields? 73 | 74 | "running the OneOfEachResultTestSuite (stateless)" using (_.oneOfEachResult) to: 75 | "report that totally 4 test was run" in: 76 | state => 77 | whenComplete(state.runAll()): 78 | possible => possible match 79 | case Left(_) => fail("Unexpected Left") 80 | case Right(result) => expect(result.total).toEqual(4) 81 | 82 | "report that 1 test was successful" in: 83 | state => 84 | whenComplete(state.runAll()): 85 | possible => possible match 86 | case Left(_) => fail("Unexpected Left") 87 | case Right(result) => expect(result.successful).toEqual(1) 88 | 89 | "report that 2 test failed" in: 90 | state => 91 | whenComplete(state.runAll()): 92 | possible => possible match 93 | case Left(_) => fail("Unexpected Left") 94 | case Right(result) => expect(result.failed).toEqual(2) 95 | 96 | "report that no test had errors" in: 97 | state => 98 | whenComplete(state.runAll()): 99 | possible => possible match 100 | case Left(_) => fail("Unexpected Left") 101 | case Right(result) => expect(result.errors).toEqual(0) 102 | 103 | "report that 1 test was ignored" in: 104 | state => 105 | whenComplete(state.runAll()): 106 | possible => possible match 107 | case Left(_) => fail("Unexpected Left") 108 | case Right(result) => expect(result.ignored).toEqual(1) 109 | 110 | "with a registered event subscriber" using (_.copy()) to : // TODO: can we use identity here? 111 | "should publish 4 events" in: 112 | state => 113 | whenComplete(state.runWithEventSubscriber()): 114 | _ => expect(state.receivedEvents()).toHaveLength(4) 115 | 116 | "running the OneOfEachResulStatefulTestSuite (stateful)" using (_.oneOfEachResultState) to: 117 | "report that 1 test was ignored" in: 118 | state => 119 | whenComplete(state.runAll()): 120 | possible => possible match 121 | case Left(_) => fail("Unexpected Left") 122 | case Right(result) => expect(result.ignored).toEqual(1) 123 | 124 | "when test suite cannot be instantiated" using (_.invalidTestSuiteClass) to: 125 | "a TestSuiteError should be received" in: 126 | state => 127 | whenComplete(state.runAll()): 128 | possible => possible match 129 | case Left(e) => expect(s"${e.ex.getClass}: ${e.ex.getMessage}").toEqual("class java.lang.ClassNotFoundException: foo.Bar") 130 | case Right(_) => expect(false).toEqual(true) 131 | 132 | "evaluting an empty suite" using (_.emptyTestSuite) to: 133 | "should not be focused" in: 134 | state => 135 | expect(state.evaluate().isFocused).toEqual(false) 136 | 137 | "evaluting the OneOfEachResultTestSuite" using (_.oneOfEachResult) to: 138 | "should not be focused" in: 139 | state => 140 | expect(state.evaluate().isFocused).toEqual(false) 141 | 142 | def expectErrorMatching(re: scala.util.matching.Regex): TestSuiteTestCase => Expectation = 143 | state => 144 | whenComplete(state.runWithEventSubscriber()): 145 | case Left(_) => fail("unexpected Left") 146 | case Right(_) => 147 | val maybeMsg = state.receivedEvents().collectFirst { case TestCaseResult(_, _, TestError(msg, _)) => msg } 148 | maybeMsg match 149 | case Some(msg) => expect(msg).toMatch("^Top-level test cases".r) 150 | case None => fail("unexpected None") 151 | 152 | /** 153 | * Wraps a runner for a specific test suite 154 | */ 155 | case class TestSuiteTestCase(suiteClassName: String = null)(using ec: ExecutionContext) extends TestSuiteRunnerTester: 156 | 157 | def emptyTestSuite = TestSuiteTestCase("intent.testdata.EmtpyTestSuite") 158 | def invalidTestSuiteClass = TestSuiteTestCase("foo.Bar") 159 | def oneOfEachResult = TestSuiteTestCase("intent.runner.OneOfEachResultTestSuite") 160 | def oneOfEachResultState = TestSuiteTestCase("intent.runner.OneOfEachResulStatefulTestSuite") 161 | def setupFailureTestSuite = TestSuiteTestCase("intent.runner.StatefulFailingTestSuite") 162 | def setupFailureAsyncTestSuite = TestSuiteTestCase("intent.runner.StatefulFailingAsyncTestSuite") 163 | def noContextTestSuite = TestSuiteTestCase("intent.runner.StatefulNoContextTestSuite") 164 | def noContextAsyncTestSuite = TestSuiteTestCase("intent.runner.StatefulNoContextAsyncTestSuite") 165 | 166 | class OneOfEachResultTestSuite extends Stateless: 167 | "successful" in success() 168 | "failed" in fail("test should fail") 169 | "ignored" ignore success() 170 | 171 | "error" in: 172 | throw new RuntimeException("test should fail") 173 | 174 | class OneOfEachResulStatefulTestSuite extends State[Unit]: 175 | "level" using (()) to: 176 | "ignored" ignore: 177 | _ => fail("Unexpected, test should be ignored") 178 | 179 | case class StatefulFailingTestState(): 180 | def fail: StatefulFailingTestState = 181 | throw new RuntimeException("intentional failure") 182 | def failAsync: Future[StatefulFailingTestState] = 183 | Future.failed(new RuntimeException("intentional failure")) 184 | def throwFail: Future[StatefulFailingTestState] = 185 | throw new RuntimeException("intentional failure") 186 | 187 | class StatefulFailingTestSuite extends State[StatefulFailingTestState]: 188 | "root" using (StatefulFailingTestState()) to: 189 | "uh oh" using (_.fail) to: 190 | "won't get here" in: 191 | _ => expect(1).toEqual(2) 192 | 193 | class StatefulFailingAsyncTestSuite extends AsyncState[StatefulFailingTestState]: 194 | "root" using (StatefulFailingTestState()) to: 195 | "uh oh async" usingAsync (_.failAsync) to: 196 | "won't get here" in: 197 | _ => expect(1).toEqual(2) 198 | "uh oh sync" using (_.fail) to: 199 | "won't get here" in: 200 | _ => expect(1).toEqual(2) 201 | "uh oh sync-fail-in-async" usingAsync (_.throwFail) to: 202 | "won't get here" in: 203 | _ => expect(1).toEqual(2) 204 | 205 | class StatefulNoContextTestSuite extends State[StatefulFailingTestState]: 206 | "won't get here" in: 207 | _ => expect(1).toEqual(2) 208 | 209 | class StatefulNoContextAsyncTestSuite extends AsyncState[StatefulFailingTestState]: 210 | "won't get here" in: 211 | _ => expect(1).toEqual(2) 212 | -------------------------------------------------------------------------------- /docs/types-of-tests/stateless/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Table-driven tests · Intent 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |
15 | 22 |
23 | 24 | 29 | 30 |
31 | 32 | 33 | 34 |

Introduction

35 | 54 | 55 | 56 | 57 |

Types of tests

58 | 77 | 78 | 79 | 80 |

Matchers

81 | 112 | 113 | 114 | 115 |

Customization

116 | 143 | 144 | 145 |
146 |
147 | 148 | 149 |
150 |
151 |
152 |

Table-driven tests

153 |

A stateless test suite extends the Statesless suite. Contexts in this style 154 | serve no other purpose than grouping tests into logical units.

155 |

Consider the following example:

156 |
import intent.{Stateless, TestSuite}
157 | 
158 | class CalculatorTest extends TestSuite with Stateless:
159 |   "A calculator" :
160 |     "can add" :
161 |       "plain numbers" in expect(Calculator().add(2, 4)).toEqual(6)
162 |       "complex numbers" in:
163 |         val a = Complex(2, 3)
164 |         val b = Complex(3, 4)
165 |         expect(Calculator().add(a, b)).toEqual(Complex(5, 7))
166 |     "can multiply" :
167 |       "plain numbers" in expect(Calculator().multiply(2, 4)).toEqual(8)
168 | 

Here, contexts serve to group tests based on the arithmetical operation used.

169 | 170 |
171 |
172 |
173 |
174 | 175 | 176 | 177 | 178 | 179 | -------------------------------------------------------------------------------- /docs/prism.js: -------------------------------------------------------------------------------- 1 | /* PrismJS 1.19.0 2 | https://prismjs.com/download.html#themes=prism-coy&languages=clike+java+scala&plugins=line-numbers */ 3 | var _self="undefined"!=typeof window?window:"undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?self:{},Prism=function(u){var c=/\blang(?:uage)?-([\w-]+)\b/i,r=0,C={manual:u.Prism&&u.Prism.manual,disableWorkerMessageHandler:u.Prism&&u.Prism.disableWorkerMessageHandler,util:{encode:function e(r){return r instanceof _?new _(r.type,e(r.content),r.alias):Array.isArray(r)?r.map(e):r.replace(/&/g,"&").replace(/e.length)return;if(!(k instanceof _)){if(h&&y!=r.length-1){if(c.lastIndex=v,!(S=c.exec(e)))break;for(var b=S.index+(f&&S[1]?S[1].length:0),w=S.index+S[0].length,A=y,P=v,x=r.length;A"+a.content+""},!u.document)return u.addEventListener&&(C.disableWorkerMessageHandler||u.addEventListener("message",function(e){var r=JSON.parse(e.data),n=r.language,t=r.code,a=r.immediateClose;u.postMessage(C.highlight(t,C.languages[n],n)),a&&u.close()},!1)),C;var e=C.util.currentScript();function n(){C.manual||C.highlightAll()}if(e&&(C.filename=e.src,e.hasAttribute("data-manual")&&(C.manual=!0)),!C.manual){var t=document.readyState;"loading"===t||"interactive"===t&&e&&e.defer?document.addEventListener("DOMContentLoaded",n):window.requestAnimationFrame?window.requestAnimationFrame(n):window.setTimeout(n,16)}return C}(_self);"undefined"!=typeof module&&module.exports&&(module.exports=Prism),"undefined"!=typeof global&&(global.Prism=Prism); 4 | Prism.languages.clike={comment:[{pattern:/(^|[^\\])\/\*[\s\S]*?(?:\*\/|$)/,lookbehind:!0},{pattern:/(^|[^\\:])\/\/.*/,lookbehind:!0,greedy:!0}],string:{pattern:/(["'])(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/,greedy:!0},"class-name":{pattern:/(\b(?:class|interface|extends|implements|trait|instanceof|new)\s+|\bcatch\s+\()[\w.\\]+/i,lookbehind:!0,inside:{punctuation:/[.\\]/}},keyword:/\b(?:if|else|while|do|for|return|in|instanceof|function|new|try|throw|catch|finally|null|break|continue)\b/,boolean:/\b(?:true|false)\b/,function:/\w+(?=\()/,number:/\b0x[\da-f]+\b|(?:\b\d+\.?\d*|\B\.\d+)(?:e[+-]?\d+)?/i,operator:/[<>]=?|[!=]=?=?|--?|\+\+?|&&?|\|\|?|[?*/~^%]/,punctuation:/[{}[\];(),.:]/}; 5 | !function(e){var t=/\b(?:abstract|assert|boolean|break|byte|case|catch|char|class|const|continue|default|do|double|else|enum|exports|extends|final|finally|float|for|goto|if|implements|import|instanceof|int|interface|long|module|native|new|null|open|opens|package|private|protected|provides|public|record|requires|return|short|static|strictfp|super|switch|synchronized|this|throw|throws|to|transient|transitive|try|uses|var|void|volatile|while|with|yield)\b/,a=/\b[A-Z](?:\w*[a-z]\w*)?\b/;e.languages.java=e.languages.extend("clike",{"class-name":[a,/\b[A-Z]\w*(?=\s+\w+\s*[;,=())])/],keyword:t,function:[e.languages.clike.function,{pattern:/(\:\:)[a-z_]\w*/,lookbehind:!0}],number:/\b0b[01][01_]*L?\b|\b0x[\da-f_]*\.?[\da-f_p+-]+\b|(?:\b\d[\d_]*\.?[\d_]*|\B\.\d[\d_]*)(?:e[+-]?\d[\d_]*)?[dfl]?/i,operator:{pattern:/(^|[^.])(?:<<=?|>>>?=?|->|--|\+\+|&&|\|\||::|[?:~]|[-+*/%&|^!=<>]=?)/m,lookbehind:!0}}),e.languages.insertBefore("java","string",{"triple-quoted-string":{pattern:/"""[ \t]*[\r\n](?:(?:"|"")?(?:\\.|[^"\\]))*"""/,greedy:!0,alias:"string"}}),e.languages.insertBefore("java","class-name",{annotation:{alias:"punctuation",pattern:/(^|[^.])@\w+/,lookbehind:!0},namespace:{pattern:/(\b(?:exports|import(?:\s+static)?|module|open|opens|package|provides|requires|to|transitive|uses|with)\s+)[a-z]\w*(?:\.[a-z]\w*)+/,lookbehind:!0,inside:{punctuation:/\./}},generics:{pattern:/<(?:[\w\s,.&?]|<(?:[\w\s,.&?]|<(?:[\w\s,.&?]|<[\w\s,.&?]*>)*>)*>)*>/,inside:{"class-name":a,keyword:t,punctuation:/[<>(),.:]/,operator:/[?&|]/}}})}(Prism); 6 | Prism.languages.scala=Prism.languages.extend("java",{keyword:/<-|=>|\b(?:abstract|case|catch|class|def|do|else|extends|final|finally|for|forSome|if|implicit|import|lazy|match|new|null|object|override|package|private|protected|return|sealed|self|super|this|throw|trait|try|type|val|var|while|with|yield)\b/,"triple-quoted-string":{pattern:/"""[\s\S]*?"""/,greedy:!0,alias:"string"},string:{pattern:/("|')(?:\\.|(?!\1)[^\\\r\n])*\1/,greedy:!0},builtin:/\b(?:String|Int|Long|Short|Byte|Boolean|Double|Float|Char|Any|AnyRef|AnyVal|Unit|Nothing)\b/,number:/\b0x[\da-f]*\.?[\da-f]+|(?:\b\d+\.?\d*|\B\.\d+)(?:e\d+)?[dfl]?/i,symbol:/'[^\d\s\\]\w*/}),delete Prism.languages.scala["class-name"],delete Prism.languages.scala.function; 7 | !function(){if("undefined"!=typeof self&&self.Prism&&self.document){var l="line-numbers",c=/\n(?!$)/g,m=function(e){var t=a(e)["white-space"];if("pre-wrap"===t||"pre-line"===t){var n=e.querySelector("code"),r=e.querySelector(".line-numbers-rows"),s=e.querySelector(".line-numbers-sizer"),i=n.textContent.split(c);s||((s=document.createElement("span")).className="line-numbers-sizer",n.appendChild(s)),s.style.display="block",i.forEach(function(e,t){s.textContent=e||"\n";var n=s.getBoundingClientRect().height;r.children[t].style.height=n+"px"}),s.textContent="",s.style.display="none"}},a=function(e){return e?window.getComputedStyle?getComputedStyle(e):e.currentStyle||null:null};window.addEventListener("resize",function(){Array.prototype.forEach.call(document.querySelectorAll("pre."+l),m)}),Prism.hooks.add("complete",function(e){if(e.code){var t=e.element,n=t.parentNode;if(n&&/pre/i.test(n.nodeName)&&!t.querySelector(".line-numbers-rows")){for(var r=!1,s=/(?:^|\s)line-numbers(?:\s|$)/,i=t;i;i=i.parentNode)if(s.test(i.className)){r=!0;break}if(r){t.className=t.className.replace(s," "),s.test(n.className)||(n.className+=" line-numbers");var l,a=e.code.match(c),o=a?a.length+1:1,u=new Array(o+1).join("");(l=document.createElement("span")).setAttribute("aria-hidden","true"),l.className="line-numbers-rows",l.innerHTML=u,n.hasAttribute("data-start")&&(n.style.counterReset="linenumber "+(parseInt(n.getAttribute("data-start"),10)-1)),e.element.appendChild(l),m(n),Prism.hooks.run("line-numbers",e)}}}}),Prism.hooks.add("line-numbers",function(e){e.plugins=e.plugins||{},e.plugins.lineNumbers=!0}),Prism.plugins.lineNumbers={getLine:function(e,t){if("PRE"===e.tagName&&e.classList.contains(l)){var n=e.querySelector(".line-numbers-rows"),r=parseInt(e.getAttribute("data-start"),10)||1,s=r+(n.children.length-1);te.length)return;if(!(k instanceof _)){if(h&&y!=r.length-1){if(c.lastIndex=v,!(S=c.exec(e)))break;for(var b=S.index+(f&&S[1]?S[1].length:0),w=S.index+S[0].length,A=y,P=v,x=r.length;A"+a.content+""},!u.document)return u.addEventListener&&(C.disableWorkerMessageHandler||u.addEventListener("message",function(e){var r=JSON.parse(e.data),n=r.language,t=r.code,a=r.immediateClose;u.postMessage(C.highlight(t,C.languages[n],n)),a&&u.close()},!1)),C;var e=C.util.currentScript();function n(){C.manual||C.highlightAll()}if(e&&(C.filename=e.src,e.hasAttribute("data-manual")&&(C.manual=!0)),!C.manual){var t=document.readyState;"loading"===t||"interactive"===t&&e&&e.defer?document.addEventListener("DOMContentLoaded",n):window.requestAnimationFrame?window.requestAnimationFrame(n):window.setTimeout(n,16)}return C}(_self);"undefined"!=typeof module&&module.exports&&(module.exports=Prism),"undefined"!=typeof global&&(global.Prism=Prism); 4 | Prism.languages.clike={comment:[{pattern:/(^|[^\\])\/\*[\s\S]*?(?:\*\/|$)/,lookbehind:!0},{pattern:/(^|[^\\:])\/\/.*/,lookbehind:!0,greedy:!0}],string:{pattern:/(["'])(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/,greedy:!0},"class-name":{pattern:/(\b(?:class|interface|extends|implements|trait|instanceof|new)\s+|\bcatch\s+\()[\w.\\]+/i,lookbehind:!0,inside:{punctuation:/[.\\]/}},keyword:/\b(?:if|else|while|do|for|return|in|instanceof|function|new|try|throw|catch|finally|null|break|continue)\b/,boolean:/\b(?:true|false)\b/,function:/\w+(?=\()/,number:/\b0x[\da-f]+\b|(?:\b\d+\.?\d*|\B\.\d+)(?:e[+-]?\d+)?/i,operator:/[<>]=?|[!=]=?=?|--?|\+\+?|&&?|\|\|?|[?*/~^%]/,punctuation:/[{}[\];(),.:]/}; 5 | !function(e){var t=/\b(?:abstract|assert|boolean|break|byte|case|catch|char|class|const|continue|default|do|double|else|enum|exports|extends|final|finally|float|for|goto|if|implements|import|instanceof|int|interface|long|module|native|new|null|open|opens|package|private|protected|provides|public|record|requires|return|short|static|strictfp|super|switch|synchronized|this|throw|throws|to|transient|transitive|try|uses|var|void|volatile|while|with|yield)\b/,a=/\b[A-Z](?:\w*[a-z]\w*)?\b/;e.languages.java=e.languages.extend("clike",{"class-name":[a,/\b[A-Z]\w*(?=\s+\w+\s*[;,=())])/],keyword:t,function:[e.languages.clike.function,{pattern:/(\:\:)[a-z_]\w*/,lookbehind:!0}],number:/\b0b[01][01_]*L?\b|\b0x[\da-f_]*\.?[\da-f_p+-]+\b|(?:\b\d[\d_]*\.?[\d_]*|\B\.\d[\d_]*)(?:e[+-]?\d[\d_]*)?[dfl]?/i,operator:{pattern:/(^|[^.])(?:<<=?|>>>?=?|->|--|\+\+|&&|\|\||::|[?:~]|[-+*/%&|^!=<>]=?)/m,lookbehind:!0}}),e.languages.insertBefore("java","string",{"triple-quoted-string":{pattern:/"""[ \t]*[\r\n](?:(?:"|"")?(?:\\.|[^"\\]))*"""/,greedy:!0,alias:"string"}}),e.languages.insertBefore("java","class-name",{annotation:{alias:"punctuation",pattern:/(^|[^.])@\w+/,lookbehind:!0},namespace:{pattern:/(\b(?:exports|import(?:\s+static)?|module|open|opens|package|provides|requires|to|transitive|uses|with)\s+)[a-z]\w*(?:\.[a-z]\w*)+/,lookbehind:!0,inside:{punctuation:/\./}},generics:{pattern:/<(?:[\w\s,.&?]|<(?:[\w\s,.&?]|<(?:[\w\s,.&?]|<[\w\s,.&?]*>)*>)*>)*>/,inside:{"class-name":a,keyword:t,punctuation:/[<>(),.:]/,operator:/[?&|]/}}})}(Prism); 6 | Prism.languages.scala=Prism.languages.extend("java",{keyword:/<-|=>|\b(?:abstract|case|catch|class|def|do|else|extends|final|finally|for|forSome|if|implicit|import|lazy|match|new|null|object|override|package|private|protected|return|sealed|self|super|this|throw|trait|try|type|val|var|while|with|yield)\b/,"triple-quoted-string":{pattern:/"""[\s\S]*?"""/,greedy:!0,alias:"string"},string:{pattern:/("|')(?:\\.|(?!\1)[^\\\r\n])*\1/,greedy:!0},builtin:/\b(?:String|Int|Long|Short|Byte|Boolean|Double|Float|Char|Any|AnyRef|AnyVal|Unit|Nothing)\b/,number:/\b0x[\da-f]*\.?[\da-f]+|(?:\b\d+\.?\d*|\B\.\d+)(?:e\d+)?[dfl]?/i,symbol:/'[^\d\s\\]\w*/}),delete Prism.languages.scala["class-name"],delete Prism.languages.scala.function; 7 | !function(){if("undefined"!=typeof self&&self.Prism&&self.document){var l="line-numbers",c=/\n(?!$)/g,m=function(e){var t=a(e)["white-space"];if("pre-wrap"===t||"pre-line"===t){var n=e.querySelector("code"),r=e.querySelector(".line-numbers-rows"),s=e.querySelector(".line-numbers-sizer"),i=n.textContent.split(c);s||((s=document.createElement("span")).className="line-numbers-sizer",n.appendChild(s)),s.style.display="block",i.forEach(function(e,t){s.textContent=e||"\n";var n=s.getBoundingClientRect().height;r.children[t].style.height=n+"px"}),s.textContent="",s.style.display="none"}},a=function(e){return e?window.getComputedStyle?getComputedStyle(e):e.currentStyle||null:null};window.addEventListener("resize",function(){Array.prototype.forEach.call(document.querySelectorAll("pre."+l),m)}),Prism.hooks.add("complete",function(e){if(e.code){var t=e.element,n=t.parentNode;if(n&&/pre/i.test(n.nodeName)&&!t.querySelector(".line-numbers-rows")){for(var r=!1,s=/(?:^|\s)line-numbers(?:\s|$)/,i=t;i;i=i.parentNode)if(s.test(i.className)){r=!0;break}if(r){t.className=t.className.replace(s," "),s.test(n.className)||(n.className+=" line-numbers");var l,a=e.code.match(c),o=a?a.length+1:1,u=new Array(o+1).join("");(l=document.createElement("span")).setAttribute("aria-hidden","true"),l.className="line-numbers-rows",l.innerHTML=u,n.hasAttribute("data-start")&&(n.style.counterReset="linenumber "+(parseInt(n.getAttribute("data-start"),10)-1)),e.element.appendChild(l),m(n),Prism.hooks.run("line-numbers",e)}}}}),Prism.hooks.add("line-numbers",function(e){e.plugins=e.plugins||{},e.plugins.lineNumbers=!0}),Prism.plugins.lineNumbers={getLine:function(e,t){if("PRE"===e.tagName&&e.classList.contains(l)){var n=e.querySelector(".line-numbers-rows"),r=parseInt(e.getAttribute("data-start"),10)||1,s=r+(n.children.length-1);t