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 new
test cases:

```scala
valueTransform("Lift task to future") {
  case Task(run) => run()
}

suiteTransform("ignore tests if foo") { tests =>
  if (ignoreTests()) Nil
  else Tests
}

testTransform("append suffix to test name") { test =>
  test.withName(test.name + "-suffix")
}
```
  • Loading branch information
Olafur Pall Geirsson committed Mar 10, 2020
1 parent 775f73e commit b8c6334
Show file tree
Hide file tree
Showing 9 changed files with 288 additions and 184 deletions.
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)
}
}
)
}

}
189 changes: 23 additions & 166 deletions munit/shared/src/main/scala/munit/FunSuite.scala
Original file line number Diff line number Diff line change
@@ -1,195 +1,52 @@
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
import scala.concurrent.duration.FiniteDuration
import java.util.concurrent.TimeUnit

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

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
munitSuiteTransform(munitTestsBuffer.toList)
}

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)
}
}
)
}
def isCI: Boolean = "true" == System.getenv("CI")
def munitIgnore: Boolean = false
def munitFlakyOK: Boolean = "true" == System.getenv("MUNIT_FLAKY_OK")
def munitTimeout: Duration = new FiniteDuration(30, TimeUnit.SECONDS)

}
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
66 changes: 66 additions & 0 deletions munit/shared/src/main/scala/munit/SuiteTransforms.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package munit

import scala.concurrent.Future
import scala.util.control.NonFatal
import scala.collection.mutable

trait SuiteTransforms { this: FunSuite =>

class SuiteTransform(val name: String, fn: List[Test] => List[Test])
extends Function1[List[Test], List[Test]] {
def apply(v1: List[Test]): List[Test] = fn(v1)
}
final val munitSuiteTransformsBuffer: mutable.ListBuffer[SuiteTransform] =
mutable.ListBuffer[SuiteTransform](
munitIgnoreSuiteTransform,
munitOnlySuiteTransform
)
final def munitSuiteTransforms(): List[List[Test] => List[Test]] =
munitSuiteTransformsBuffer.toList
final def suiteTransform(name: String)(fn: List[Test] => List[Test]): Unit =
munitSuiteTransformsBuffer += new SuiteTransform(name, fn)

final def munitSuiteTransform(tests: List[Test]): List[Test] = {
try {
munitSuiteTransforms().foldLeft(tests) {
case (ts, fn) => fn(ts)
}
} catch {
case NonFatal(e) =>
List(
new Test(
"munitSuiteTransform",
() => Future.failed(e)
)(Location.empty)
)
}
}

final def munitIgnoreSuiteTransform: SuiteTransform =
new SuiteTransform("munitIgnore", { tests =>
if (munitIgnore) Nil
else tests
})

final def munitOnlySuiteTransform: SuiteTransform =
new SuiteTransform("only", { tests =>
val onlySuite = tests.filter(_.tags(Only))
if (onlySuite.nonEmpty) {
if (!isCI) {
onlySuite
} else {
onlySuite.map(t =>
if (t.tags(Only)) {
t.withBody[TestValue](() =>
fail("'Only' tag is not allowed when `isCI=true`")(t.location)
)
} else {
t
}
)
}
} else {
tests
}
})
}
Loading

0 comments on commit b8c6334

Please sign in to comment.