Skip to content

Commit

Permalink
Use consistent solution to customize tests, suites and values
Browse files Browse the repository at this point in the history
Previously, users could override several methods to customize how tests
were transformed, how suites filtered tests and how values got lifted
into futures. While this system worked OK, it wasn't easy to explain
since it was quite inconsistent.

Now, this commit introduces three consistent solutions to transform:

* test cases: a function `Test => Test`
* test suites: a function `List[Test] => List[Test]`
* test values: a partial function `Any => Future[Any]`

Users register new transforms using a similar API as you register
fixtures:

```scala
override def munitTestTransforms: List[TestTransform] =
  super.munitTestTransforms ++ List(
    new TestTransform("append scala version", { test =>
      test.withName(test.name + "-" + scalaVersion)
    })
  )
```
  • Loading branch information
Olafur Pall Geirsson committed Mar 11, 2020
1 parent 775f73e commit 94a596c
Show file tree
Hide file tree
Showing 11 changed files with 327 additions and 221 deletions.
38 changes: 21 additions & 17 deletions docs/tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,11 @@ to add make sure that `LazyFuture.run()` gets called.
```scala mdoc
import scala.concurrent.ExecutionContext.Implicits.global
class TaskSuite extends munit.FunSuite {
override def munitTestValue(testValue: => Any): Future[Any] =
super.munitTestValue(testValue).flatMap {
override def munitValueTransforms = super.munitValueTransforms ++ List(
new ValueTransform("LazyFuture", {
case LazyFuture(run) => run()
case value => Future.successful(value)
}
})
)
implicit val ec = ExecutionContext.global
test("ok-task") {
LazyFuture {
Expand Down Expand Up @@ -198,15 +198,17 @@ evaluate the body multiple times.
```scala mdoc
case class Rerun(count: Int) extends munit.Tag("Rerun")
class MyWindowsSuite extends munit.FunSuite {
override def munitRunTest(options: munit.TestOptions, body: () => Future[Any]): Future[Any] = {
val rerunCount = options.tags.collectFirst {
case Rerun(n) => n
}.getOrElse(1)
val futures: Seq[Future[Any]] = 1.to(rerunCount).map(_ =>
super.munitRunTest(options, body))
val result: Future[Seq[Any]] = Future.sequence(futures)
result
}
override def munitTestTransforms = super.munitTestTransforms ++ List(
new TestTransform("Rerun", { test =>
val rerunCount = test.tags.collectFirst {
case Rerun(n) => n
}.getOrElse(1)
test.withBody[TestValue](() => {
val futures: Seq[Future[Any]] = 1.to(rerunCount).map(_ => test.body())
Future.sequence(futures)
})
}),
)
test("files".tag(Rerun(10))) {
println("Hello") // will run 10 times
}
Expand All @@ -229,8 +231,11 @@ condition.
```scala mdoc
class ScalaVersionSuite extends munit.FunSuite {
val scalaVersion = scala.util.Properties.versionNumberString
override def munitNewTest(test: Test): Test =
test.withName(test.name + "-" + scalaVersion)
override def munitTestTransforms = super.munitTestTransforms ++ List(
new TestTransform("append Scala version", { test =>
test.withName(test.name + "-" + scalaVersion)
})
)
test("foo") {
assert(!scalaVersion.startsWith("2.11"))
}
Expand Down Expand Up @@ -295,8 +300,7 @@ in your project.
```scala mdoc
abstract class BaseSuite extends munit.FunSuite {
override val munitTimeout = Duration(1, "min")
override def munitTestValue(value: => Any): Future[Any] =
???
override def munitTestTransforms = super.munitTestTransforms ++ List(???)
// ...
}
class MyFirstSuite extends BaseSuite { /* ... */ }
Expand Down
51 changes: 51 additions & 0 deletions munit/shared/src/main/scala/munit/FunFixtures.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package munit

trait FunFixtures { self: FunSuite =>

class FunFixture[T](
val setup: TestOptions => T,
val teardown: T => Unit
) {
def test(options: TestOptions)(
body: T => Any
)(implicit loc: Location): Unit = {
self.test(options) {
val argument = setup(options)
try body(argument)
finally teardown(argument)
}(loc)
}
}
object FunFixture {
def map2[A, B](a: FunFixture[A], b: FunFixture[B]): FunFixture[(A, B)] =
new FunFixture[(A, B)](
setup = { options =>
(a.setup(options), b.setup(options))
},
teardown = {
case (argumentA, argumentB) =>
try a.teardown(argumentA)
finally b.teardown(argumentB)
}
)
def map3[A, B, C](
a: FunFixture[A],
b: FunFixture[B],
c: FunFixture[C]
): FunFixture[(A, B, C)] =
new FunFixture[(A, B, C)](
setup = { options =>
(a.setup(options), b.setup(options), c.setup(options))
},
teardown = {
case (argumentA, argumentB, argumentC) =>
try a.teardown(argumentA)
finally {
try b.teardown(argumentB)
finally c.teardown(argumentC)
}
}
)
}

}
185 changes: 17 additions & 168 deletions munit/shared/src/main/scala/munit/FunSuite.scala
Original file line number Diff line number Diff line change
@@ -1,195 +1,44 @@
package munit

import munit.internal.console.StackTraces
import munit.internal.FutureCompat._

import scala.collection.mutable
import scala.util.Failure
import scala.util.Success
import scala.concurrent.Future
import scala.util.control.NonFatal
import scala.util.Try
import scala.concurrent.duration.Duration
import munit.internal.PlatformCompat

abstract class FunSuite
extends Suite
with Assertions
with TestOptionsConversions { self =>
with FunFixtures
with TestOptionsConversions
with TestTransforms
with SuiteTransforms
with ValueTransforms { self =>

final type TestValue = Future[Any]

def isCI: Boolean = "true" == System.getenv("CI")
def munitIgnore: Boolean = false
def munitFlakyOK: Boolean = "true" == System.getenv("MUNIT_FLAKY_OK")

private val defaultTimeout = Duration(30, "s")
def munitTimeout: Duration = defaultTimeout
val munitTestsBuffer: mutable.ArrayBuffer[Test] =
mutable.ArrayBuffer.empty[Test]
final val munitTestsBuffer: mutable.ListBuffer[Test] =
mutable.ListBuffer.empty[Test]
def munitTests(): Seq[Test] = {
if (munitIgnore) {
Nil
} else {
val onlyTests = munitTestsBuffer.filter(_.tags(Only))
if (onlyTests.nonEmpty) {
if (isCI) {
onlyTests.toSeq.map(t =>
if (t.tags(Only)) {
t.withBody[TestValue](() =>
fail("'Only' tag is not allowed when `isCI=true`")(t.location)
)
} else {
t
}
)
} else {
onlyTests.toSeq
}
} else {
munitTestsBuffer.toSeq
}
}
munitSuiteTransform(munitTestsBuffer.toList)
}

def munitTestValue(testValue: => Any): Future[Any] = {
// Takes an arbitrarily nested future `Future[Future[Future[...]]]` and
// returns a `Future[T]` where `T` is not a `Future`.
def flattenFuture(future: Future[_]): Future[_] = {
val nested = future.map {
case f: Future[_] => flattenFuture(f)
case x => Future.successful(x)
}(munitExecutionContext)
nested.flattenCompat(munitExecutionContext)
}
val wrappedFuture = Future.fromTry(Try(StackTraces.dropOutside(testValue)))
val flatFuture = flattenFuture(wrappedFuture)
val awaitedFuture = PlatformCompat.waitAtMost(flatFuture, munitTimeout)
awaitedFuture
}

def munitNewTest(test: Test): Test =
test

def test(name: String)(
body: => Any
)(implicit loc: Location): Unit = {
def test(name: String)(body: => Any)(implicit loc: Location): Unit = {
test(new TestOptions(name, Set.empty, loc))(body)
}

def test(options: TestOptions)(
body: => Any
)(implicit loc: Location): Unit = {
munitTestsBuffer += munitNewTest(
def test(options: TestOptions)(body: => Any)(implicit loc: Location): Unit = {
munitTestsBuffer += munitTestTransform(
new Test(
options.name, { () =>
munitRunTest(options, () => {
try {
munitTestValue(body)
} catch {
case NonFatal(e) =>
Future.failed(e)
}
})
try {
munitValueTransform(body)
} catch {
case NonFatal(e) =>
Future.failed(e)
}
},
options.tags.toSet,
loc
)
)
}

def munitRunTest(
options: TestOptions,
body: () => Future[Any]
): Future[Any] = {
if (options.tags(Fail)) {
munitExpectFailure(options, body)
} else if (options.tags(Flaky)) {
munitFlaky(options, body)
} else {
body()
}
}

def munitFlaky(
options: TestOptions,
body: () => Future[Any]
): Future[Any] = {
body().transformCompat {
case Success(value) => Success(value)
case Failure(exception) =>
if (munitFlakyOK) {
Success(new TestValues.FlakyFailure(exception))
} else {
throw exception
}
}(munitExecutionContext)
}

def munitExpectFailure(
options: TestOptions,
body: () => Future[Any]
): Future[Any] = {
body().transformCompat {
case Success(value) =>
Failure(
throw new FailException(
munitLines.formatLine(
options.location,
"expected failure but test passed"
),
options.location
)
)
case Failure(exception) =>
Success(())
}(munitExecutionContext)
}

class FunFixture[T](
val setup: TestOptions => T,
val teardown: T => Unit
) {
def test(options: TestOptions)(
body: T => Any
)(implicit loc: Location): Unit = {
self.test(options) {
val argument = setup(options)
try body(argument)
finally teardown(argument)
}(loc)
}
}
object FunFixture {
def map2[A, B](a: FunFixture[A], b: FunFixture[B]): FunFixture[(A, B)] =
new FunFixture[(A, B)](
setup = { options =>
(a.setup(options), b.setup(options))
},
teardown = {
case (argumentA, argumentB) =>
try a.teardown(argumentA)
finally b.teardown(argumentB)
}
)
def map3[A, B, C](
a: FunFixture[A],
b: FunFixture[B],
c: FunFixture[C]
): FunFixture[(A, B, C)] =
new FunFixture[(A, B, C)](
setup = { options =>
(a.setup(options), b.setup(options), c.setup(options))
},
teardown = {
case (argumentA, argumentB, argumentC) =>
try a.teardown(argumentA)
finally {
try b.teardown(argumentB)
finally c.teardown(argumentC)
}
}
)
}

}
4 changes: 4 additions & 0 deletions munit/shared/src/main/scala/munit/GenericTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ class GenericTest[T](
withTags(tags + newTag)
def withLocation(newLocation: Location): GenericTest[T] =
copy(location = newLocation)

def withBodyMap[A](newBody: T => A): GenericTest[A] =
withBody[A](() => newBody(body()))

private[this] def copy[A](
name: String = this.name,
body: () => A = this.body,
Expand Down
Loading

0 comments on commit 94a596c

Please sign in to comment.