From 69175cb1e826979e572a12e639393cfd3d41af6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93lafur=20P=C3=A1ll=20Geirsson?= Date: Sun, 10 Oct 2021 16:00:05 +0200 Subject: [PATCH 01/10] Add support for async fixtures Previously, it was not possible to create fixtures that loaded asynchronously. It was possible to work around this limitation on the JVM by awaiting on futures, but there was no workaround for Scala.js. This commit adds support to return futures (and anything that converts to futures) from the beforeAll/beforeEach/afterEach/afterAll methods. Supersedes https://github.com/scalameta/munit/pull/418. This commit is inspired by that PR but uses a different approach: - No new `AsyncFixture[T]` type. This simplies the logic in `MUnitRunner` and reduces the size of the MUnit public API. The downside is that we introduce a breaking change to the existing `Fixture[T]` API, which will require bumping the version to v1.0.0 for the next release. - This approach supports any `Future`-like values by hooking into the default evaluation of test bodies via `munitValueTransforms`. Co-authored-by: Daniel Esik --- docs/fixtures.md | 58 +++- docs/tests.md | 66 +--- .../src/main/scala/munit/BeforeEach.scala | 9 + .../shared/src/main/scala/munit/Fixture.scala | 27 ++ .../src/main/scala/munit/FunSuite.scala | 2 - .../src/main/scala/munit/FutureFixture.scala | 15 + .../main/scala/munit/GenericBeforeEach.scala | 9 - .../src/main/scala/munit/MUnitRunner.scala | 302 +++++++++++------- munit/shared/src/main/scala/munit/Suite.scala | 35 +- .../main/scala/munit/SuiteTransforms.scala | 2 +- .../munit/{GenericTest.scala => Test.scala} | 29 +- .../src/main/scala/munit/TestTransforms.scala | 2 +- .../src/main/scala/munit/UnitFixture.scala | 11 + .../shared/src/main/scala/munit/package.scala | 9 + .../test/scala/munit/AsyncFixtureSuite.scala | 97 ++++++ ....scala => AsyncFunFixtureOrderSuite.scala} | 2 +- ...la => AsyncFunFixtureFrameworkSuite.scala} | 6 +- ...scala => FixtureOrderFrameworkSuite.scala} | 14 +- .../ValueTransformCrashFrameworkSuite.scala | 6 +- .../src/test/scala/munit/FrameworkSuite.scala | 4 +- .../scala/munit/SuiteLocalFixtureSuite.scala | 4 +- .../scala/munit/TestLocalFixtureSuite.scala | 2 +- website/.tool-versions | 1 + website/blog/2020-02-01-hello-world.md | 43 --- 24 files changed, 466 insertions(+), 289 deletions(-) create mode 100644 munit/shared/src/main/scala/munit/BeforeEach.scala create mode 100644 munit/shared/src/main/scala/munit/Fixture.scala create mode 100644 munit/shared/src/main/scala/munit/FutureFixture.scala delete mode 100644 munit/shared/src/main/scala/munit/GenericBeforeEach.scala rename munit/shared/src/main/scala/munit/{GenericTest.scala => Test.scala} (65%) create mode 100644 munit/shared/src/main/scala/munit/UnitFixture.scala create mode 100644 tests/jvm/src/test/scala/munit/AsyncFixtureSuite.scala rename tests/jvm/src/test/scala/munit/{AsyncFixtureOrderSuite.scala => AsyncFunFixtureOrderSuite.scala} (95%) rename tests/shared/src/main/scala/munit/{AsyncFixtureFrameworkSuite.scala => AsyncFunFixtureFrameworkSuite.scala} (94%) rename tests/shared/src/main/scala/munit/{FixtureFrameworkSuite.scala => FixtureOrderFrameworkSuite.scala} (94%) create mode 100644 website/.tool-versions diff --git a/docs/fixtures.md b/docs/fixtures.md index 2511f24b..42351598 100644 --- a/docs/fixtures.md +++ b/docs/fixtures.md @@ -57,12 +57,12 @@ to reusable or ad-hoc fixtures when necessary. ## Reusable test-local fixtures Reusable test-local fixtures are more powerful than functional test-local -fixtures because they can declare custom logic that gets evaluted before each +fixtures because they can declare custom logic that gets evaluated before each local test case and get torn down after each test case. These increased capabilities come at the price of ergonomics of the API. -Override the `beforeEach()` and `afterEach()` methods in the `Fixture[T]` trait -to configure a reusable test-local fixture. +Override the `beforeEach()`, `afterEach()` and `munitFixtures` methods in the +`Fixture[T]` trait to configure a reusable test-local fixture. ```scala mdoc:reset import java.nio.file._ @@ -120,6 +120,58 @@ class MySuite extends munit.FunSuite { } ``` +## Asynchronous fixtures + +Return a `Future`-like value from the methods `beforeAll`, `beforeEach`, +`afterEach` and `afterAll` to make an asynchronous fixture. By default, only +`Future[_]` values are recognized. Override `munitValueTransforms` to add +support for writing async fixture with other `Future`-like types, see +[declare async tests](tests.md#declare-async-test) for more details. + +```scala mdoc:reset +import java.nio.file._ +import java.sql.Connection +import java.sql.DriverManager +import munit._ +import scala.concurrent.Future +import scala.concurrent.ExecutionContext.Implicits.global + +class AsyncFilesSuite extends FunSuite { + + // Test-local async fixture + val file = new Fixture[Path]("files") { + var file: Path = null + def apply() = file + override def beforeEach(context: BeforeEach): Future[Unit] = Future { + file = Files.createTempFile("files", context.test.name) + } + override def afterEach(context: AfterEach): Future[Unit] = Future { + // Always gets called, even if test failed. + Files.deleteIfExists(file) + } + } + + // Suite-local async fixture + val db = new Fixture[Connection]("database") { + private var connection: Connection = null + def apply() = connection + override def beforeAll(): Future[Unit] = Future { + connection = DriverManager.getConnection("jdbc:h2:mem:", "sa", null) + } + override def afterAll(): Future[Unit] = Future { + connection.close() + } + } + + override def munitFixtures = List(file, db) + + test("exists") { + // `file` is the temporary file that was created for this test case. + assert(Files.exists(file())) + } +} +``` + ## Ad-hoc test-local fixtures Override `beforeEach()` and `afterEach()` to add custom logic that should run diff --git a/docs/tests.md b/docs/tests.md index 10bbde94..3463d7fa 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -86,8 +86,8 @@ test("buggy-task") { ``` Since tasks are lazy, a test that returns `LazyFuture[T]` will always pass since -you need to call `run()` to start the task execution. Override `munitValueTransforms` -to make sure that `LazyFuture.run()` gets called. +you need to call `run()` to start the task execution. Override +`munitValueTransforms` to make sure that `LazyFuture.run()` gets called. ```scala mdoc import scala.concurrent.ExecutionContext.Implicits.global @@ -118,9 +118,10 @@ parallel test suite execution in sbt, add the following setting to `build.sbt`. Test / parallelExecution := false ``` -In case you do not run your tests in parallel, you can also disable buffered logging, -which is on by default to prevent test results of multiple suites from appearing interleaved. -Switching buffering off would give you immediate feedback on the console while a suite is running. +In case you do not run your tests in parallel, you can also disable buffered +logging, which is on by default to prevent test results of multiple suites from +appearing interleaved. Switching buffering off would give you immediate feedback +on the console while a suite is running. ```sh Test / testOptions += Tests.Argument(TestFrameworks.MUnit, "-b") @@ -199,9 +200,9 @@ bug report but don't have a solution to fix the issue yet. ## Customize evaluation of tests with tags -Override `munitTestTransforms()` to extend the default behavior for how test bodies are -evaluated. For example, use this feature to implement a `Rerun(N)` modifier to -evaluate the body multiple times. +Override `munitTestTransforms()` to extend the default behavior for how test +bodies are evaluated. For example, use this feature to implement a `Rerun(N)` +modifier to evaluate the body multiple times. ```scala mdoc case class Rerun(count: Int) extends munit.Tag("Rerun") @@ -228,9 +229,9 @@ class MyRerunSuite extends munit.FunSuite { } ``` -The `munitTestTransforms()` method is similar to `munitValueTransforms()` but is different in -that you also have access information about the test in `TestOptions` such as -tags. +The `munitTestTransforms()` method is similar to `munitValueTransforms()` but is +different in that you also have access information about the test in +`TestOptions` such as tags. ## Customize test name based on a dynamic condition @@ -316,46 +317,3 @@ abstract class BaseSuite extends munit.FunSuite { class MyFirstSuite extends BaseSuite { /* ... */ } class MySecondSuite extends BaseSuite { /* ... */ } ``` - -## Roll our own testing library with `munit.Suite` - -The `munit.FunSuite` class comes with a lot of built-in functionality such as -assertions, fixtures, `munitTimeout()` helpers and more. These features may not -be necessary or even desirable when writing tests. You may sometimes prefer a -smaller API. - -Extend the base class `munit.Suite` to implement a minimal test suite that -includes no optional MUnit features. At its core, MUnit operates on a data -structure `GenericTest[TestValue]` where the type parameter `TestValue` -represents the return value of test bodies. This type parameter can be -customized per-suite. In `munit.FunSuite`, the type parameter `TestValue` is -defined as `Any` and `type Test = GenericTest[Any]`. - -Below is an example custom test suite with `type TestValue = Future[String]`. - -```scala -class MyCustomSuite extends munit.Suite { - override type TestValue = Future[String] - override def munitTests() = List( - new Test( - "name", - // compile error if it's not a Future[String] - body = () => Future.successful("Hello world!"), - tags = Set.empty[Tag], - location = implicitly[Location] - ) - ) -} -``` - -Some use-cases where you may want to define a custom `munit.Suite`: - -- implement APIs that mimic testing libraries to simplify the migration to MUnit -- design stricter APIs that don't use `Any` -- design purely functional APIs with no publicly facing side-effects - -In application code, it's desirable to use strong types avoid mutable state. -However, it's not clear that those best practices yield the same cost/benefit -ratio when writing test code. MUnit intentionally exposes types such `Any` and -side-effecting methods like `test("name") { ... }` because they subjectively -make the testing API nice-to-use. diff --git a/munit/shared/src/main/scala/munit/BeforeEach.scala b/munit/shared/src/main/scala/munit/BeforeEach.scala new file mode 100644 index 00000000..91abf349 --- /dev/null +++ b/munit/shared/src/main/scala/munit/BeforeEach.scala @@ -0,0 +1,9 @@ +package munit + +class BeforeEach( + val test: Test +) extends Serializable + +class AfterEach( + val test: Test +) extends Serializable diff --git a/munit/shared/src/main/scala/munit/Fixture.scala b/munit/shared/src/main/scala/munit/Fixture.scala new file mode 100644 index 00000000..5f296ce5 --- /dev/null +++ b/munit/shared/src/main/scala/munit/Fixture.scala @@ -0,0 +1,27 @@ +package munit + +/** + * @param name The name of this fixture, used for displaying an error message if + * `beforeAll()` or `afterAll()` fail. + */ +abstract class Fixture[T](val fixtureName: String) { + + /** The value produced by this suite-local fixture that can be reused for all test cases. */ + def apply(): T + + /** Runs once before the test suite starts */ + def beforeAll(): Any = () + + /** + * Runs before each individual test case. + * An error in this method aborts the test case. + */ + def beforeEach(context: BeforeEach): Any = () + + /** Runs after each individual test case. */ + def afterEach(context: AfterEach): Any = () + + /** Runs once after the test suite has finished, regardless if the tests failed or not. */ + def afterAll(): Any = () + +} diff --git a/munit/shared/src/main/scala/munit/FunSuite.scala b/munit/shared/src/main/scala/munit/FunSuite.scala index 7ef2ee1b..5b6e6725 100644 --- a/munit/shared/src/main/scala/munit/FunSuite.scala +++ b/munit/shared/src/main/scala/munit/FunSuite.scala @@ -17,8 +17,6 @@ abstract class FunSuite with SuiteTransforms with ValueTransforms { self => - final type TestValue = Future[Any] - final val munitTestsBuffer: mutable.ListBuffer[Test] = mutable.ListBuffer.empty[Test] def munitTests(): Seq[Test] = { diff --git a/munit/shared/src/main/scala/munit/FutureFixture.scala b/munit/shared/src/main/scala/munit/FutureFixture.scala new file mode 100644 index 00000000..dccac4c1 --- /dev/null +++ b/munit/shared/src/main/scala/munit/FutureFixture.scala @@ -0,0 +1,15 @@ +package munit + +import scala.concurrent.Future + +/** + * Extend this class if you want to create a Fixture where all methods have `Future[Any]` as the result type. + */ +abstract class FutureFixture[T](name: String) extends Fixture[Future[T]](name) { + override def beforeAll(): Future[Any] = Future.successful(()) + override def beforeEach(context: BeforeEach): Future[Any] = + Future.successful(()) + override def afterEach(context: AfterEach): Future[Any] = + Future.successful(()) + override def afterAll(): Future[Any] = Future.successful(()) +} diff --git a/munit/shared/src/main/scala/munit/GenericBeforeEach.scala b/munit/shared/src/main/scala/munit/GenericBeforeEach.scala deleted file mode 100644 index dcaf6e64..00000000 --- a/munit/shared/src/main/scala/munit/GenericBeforeEach.scala +++ /dev/null @@ -1,9 +0,0 @@ -package munit - -class GenericBeforeEach[T]( - val test: GenericTest[T] -) extends Serializable - -class GenericAfterEach[T]( - val test: GenericTest[T] -) extends Serializable diff --git a/munit/shared/src/main/scala/munit/MUnitRunner.scala b/munit/shared/src/main/scala/munit/MUnitRunner.scala index ee8eeb54..7f11ea39 100644 --- a/munit/shared/src/main/scala/munit/MUnitRunner.scala +++ b/munit/shared/src/main/scala/munit/MUnitRunner.scala @@ -1,32 +1,31 @@ package munit -import munit.internal.junitinterface.Configurable +import munit.internal.FutureCompat._ import munit.internal.PlatformCompat +import munit.internal.console.Printers +import munit.internal.console.StackTraces +import munit.internal.junitinterface.Configurable +import munit.internal.junitinterface.Settings +import org.junit.AssumptionViolatedException import org.junit.runner.Description +import org.junit.runner.Runner +import org.junit.runner.manipulation.Filter +import org.junit.runner.manipulation.Filterable +import org.junit.runner.notification.Failure import org.junit.runner.notification.RunNotifier + import java.lang.reflect.InvocationTargetException import java.lang.reflect.Modifier import java.lang.reflect.UndeclaredThrowableException - -import munit.internal.FutureCompat._ -import munit.internal.console.StackTraces -import org.junit.runner.notification.Failure - -import scala.util.control.NonFatal -import org.junit.runner.manipulation.Filterable -import org.junit.runner.manipulation.Filter -import org.junit.runner.Runner -import org.junit.AssumptionViolatedException - +import java.util.concurrent.ExecutionException import scala.collection.mutable -import scala.util.Try import scala.concurrent.Await -import scala.concurrent.duration.Duration -import scala.concurrent.Future import scala.concurrent.ExecutionContext -import java.util.concurrent.ExecutionException -import munit.internal.junitinterface.Settings -import munit.internal.console.Printers +import scala.concurrent.Future +import scala.concurrent.duration.Duration +import scala.util.Success +import scala.util.Try +import scala.util.control.NonFatal class MUnitRunner(val cls: Class[_ <: Suite], newInstance: () => Suite) extends Runner @@ -43,13 +42,25 @@ class MUnitRunner(val cls: Class[_ <: Suite], newInstance: () => Suite) @volatile private var settings: Settings = Settings.defaults() @volatile private var suiteAborted: Boolean = false - private val descriptions: mutable.Map[suite.Test, Description] = - mutable.Map.empty[suite.Test, Description] + private val descriptions: mutable.Map[Test, Description] = + mutable.Map.empty[Test, Description] private val testNames: mutable.Set[String] = mutable.Set.empty[String] - private lazy val munitTests: mutable.ArrayBuffer[suite.Test] = - mutable.ArrayBuffer[suite.Test](suite.munitTests(): _*) + private lazy val munitTests: mutable.ArrayBuffer[Test] = + mutable.ArrayBuffer[Test](suite.munitTests(): _*) + private val suiteFixture = new Fixture[Unit](cls.getName()) { + def apply(): Unit = () + override def beforeAll(): Any = + suite.beforeAll() + override def beforeEach(context: BeforeEach): Any = + suite.beforeEach(context) + override def afterEach(context: AfterEach): Any = suite.afterEach(context) + override def afterAll(): Any = + suite.afterAll() + } + private lazy val munitFixtures: List[Fixture[_]] = + suiteFixture :: suite.munitFixtures.toList override def filter(filter: Filter): Unit = { val newTests = munitTests.filter { t => @@ -62,7 +73,7 @@ class MUnitRunner(val cls: Class[_ <: Suite], newInstance: () => Suite) this.settings = settings } - def createTestDescription(test: suite.Test): Description = { + private def createTestDescription(test: Test): Description = { descriptions.getOrElseUpdate( test, { val escapedName = Printers.escapeNonVisible(test.name) @@ -114,30 +125,45 @@ class MUnitRunner(val cls: Class[_ <: Suite], newInstance: () => Suite) } catch { case ex: Throwable => Future.successful( - fireHiddenTest(notifier, "expected error running tests", ex) + fireFailedHiddenTest(notifier, "expected error running tests", ex) ) } finally { notifier.fireTestSuiteFinished(description) } } - private def runAsyncTestsSynchronously( + private def runTests( notifier: RunNotifier - ): Future[Unit] = { - def loop(it: Iterator[suite.Test]): Future[Unit] = + ): Future[List[Try[Boolean]]] = { + runAsyncTestsSynchronously( + munitTests.iterator.map(t => runTest(notifier, t)) + ) + } + + private def runAsyncTestsSynchronously[A]( + tests: Iterator[Future[A]] + ): Future[List[Try[A]]] = { + def loop( + it: Iterator[Future[A]], + acc: mutable.ListBuffer[Try[A]] + ): Future[List[Try[A]]] = if (!it.hasNext) { - Future.successful(()) + Future.successful(acc.toList) } else { - val future = runTest(notifier, it.next()) + val future = it.next() future.value match { - case Some(_) => + case Some(value) => + acc += value // use tail-recursive call if possible to keep stack traces clean. - loop(it) + loop(it, acc) case None => - future.flatMap(_ => loop(it)) + future.flatMap(t => { + acc += util.Success(t) + loop(it, acc) + }) } } - loop(munitTests.iterator) + loop(tests, mutable.ListBuffer.empty) } private def runAll(notifier: RunNotifier): Future[Unit] = { @@ -146,83 +172,96 @@ class MUnitRunner(val cls: Class[_ <: Suite], newInstance: () => Suite) notifier.fireTestIgnored(description) return Future.successful(()) } - var isBeforeAllRun = false - val result = { - val isContinue = runBeforeAll(notifier) - isBeforeAllRun = isContinue - if (isContinue) { - runAsyncTestsSynchronously(notifier) - } else { - Future.successful(()) - } - } - result.transformCompat { s => - if (isBeforeAllRun) { - runAfterAll(notifier) + for { + beforeAll <- runBeforeAll(notifier) + _ <- { + if (beforeAll.isSuccess) { + runTests(notifier) + } else { + Future.successful(Nil) + } } - s - } + _ <- runAfterAll(notifier, beforeAll) + } yield () } - private def runBeforeAll(notifier: RunNotifier): Boolean = { - var isContinue = runHiddenTest(notifier, "beforeAll", suite.beforeAll()) - suite.munitFixtures.foreach { fixture => - isContinue &= runHiddenTest( - notifier, - s"beforeAllFixture(${fixture.fixtureName})", - fixture.beforeAll() + private[munit] class BeforeAllResult( + val isSuccess: Boolean, + val loadedFixtures: List[Fixture[_]], + val errors: List[Throwable] + ) + + private def runBeforeAll(notifier: RunNotifier): Future[BeforeAllResult] = { + val result: Future[List[Try[(Fixture[_], Boolean)]]] = + runAsyncTestsSynchronously( + munitFixtures.iterator.map(f => + runHiddenTest( + notifier, + s"beforeAll(${f.fixtureName})", + () => f.beforeAll() + ).map(isSuccess => f -> isSuccess) + ) ) + result.map { results => + val loadedFixtures = results.collect { case Success((fixture, true)) => + fixture + } + val errors = results.collect { case util.Failure(ex) => ex } + val isSuccess = loadedFixtures.length == results.length + new BeforeAllResult(isSuccess, loadedFixtures, errors) } - isContinue } - private def runAfterAll(notifier: RunNotifier): Unit = { - suite.munitFixtures.foreach { fixture => - runHiddenTest( - notifier, - s"afterAllFixture(${fixture.fixtureName})", - fixture.afterAll() + + private def runAfterAll( + notifier: RunNotifier, + beforeAll: BeforeAllResult + ): Future[Unit] = { + runAsyncTestsSynchronously[Boolean]( + beforeAll.loadedFixtures.iterator.map(f => + runHiddenTest( + notifier, + s"afterAll(${f.fixtureName})", + () => f.afterAll() + ) ) - } - runHiddenTest(notifier, "afterAll", suite.afterAll()) + ).map(_ => ()) } - class BeforeEachResult( + private[munit] class BeforeEachResult( val error: Option[Throwable], - val loadedFixtures: List[suite.Fixture[_]] + val loadedFixtures: List[Fixture[_]] ) + private def runBeforeEach( - test: suite.Test - ): BeforeEachResult = { - val beforeEach = new GenericBeforeEach(test) - val fixtures = mutable.ListBuffer.empty[suite.Fixture[_]] - val error = foreachUnsafe( - List(() => suite.beforeEach(beforeEach)) ++ - suite.munitFixtures.map(fixture => - () => { - fixture.beforeEach(beforeEach) - fixtures += fixture - () - } - ) - ) - new BeforeEachResult(error.failed.toOption, fixtures.toList) + test: Test + ): Future[BeforeAllResult] = { + val context = new BeforeEach(test) + val fixtures = mutable.ListBuffer.empty[Fixture[_]] + runAsyncTestsSynchronously( + munitFixtures.iterator.map(f => + valueTransform(() => f.beforeEach(context)).map(_ => f) + ) + ).map { results => + val loadedFixtures = results.collect { case Success(f) => f } + val errors = results.collect { case util.Failure(ex) => ex } + val isSuccess = loadedFixtures.length == results.length + new BeforeAllResult(isSuccess, loadedFixtures, errors) + } } private def runAfterEach( - test: suite.Test, - fixtures: List[suite.Fixture[_]] - ): Unit = { - val afterEach = new GenericAfterEach(test) - val error = foreachUnsafe( - fixtures.map(fixture => () => fixture.afterEach(afterEach)) ++ - List(() => suite.afterEach(afterEach)) - ) - error.get // throw exception if it exists. + test: Test, + fixtures: List[Fixture[_]] + ): Future[Unit] = { + val context = new AfterEach(test) + runAsyncTestsSynchronously( + fixtures.iterator.map(f => valueTransform(() => f.afterEach(context))) + ).map(_ => ()) } private def runTest( notifier: RunNotifier, - test: suite.Test + test: Test ): Future[Boolean] = { val description = createTestDescription(test) @@ -287,20 +326,25 @@ class MUnitRunner(val cls: Class[_ <: Suite], newInstance: () => Suite) private def runTestBody( notifier: RunNotifier, description: Description, - test: suite.Test + test: Test ): Future[Unit] = { - val result: Future[Any] = StackTraces.dropOutside { - val beforeEachResult = runBeforeEach(test) - val any = beforeEachResult.error match { - case None => - try test.body() - finally runAfterEach(test, beforeEachResult.loadedFixtures) - case Some(error) => - try runAfterEach(test, beforeEachResult.loadedFixtures) - finally throw error + val result: Future[Any] = + runBeforeEach(test).flatMap[Any] { beforeEach => + beforeEach.errors match { + case Nil => + StackTraces + .dropOutside(test.body()) + .transformWithCompat(result => + runAfterEach(test, beforeEach.loadedFixtures).transform(_ => + result + ) + ) + case error :: errors => + errors.foreach(err => error.addSuppressed(err)) + try runAfterEach(test, beforeEach.loadedFixtures) + finally throw error + } } - futureFromAny(any) - } result.map { case f: TestValues.FlakyFailure => trimStackTrace(f) @@ -312,11 +356,22 @@ class MUnitRunner(val cls: Class[_ <: Suite], newInstance: () => Suite) } } - private def foreachUnsafe(thunks: Iterable[() => Unit]): Try[Unit] = { + private[munit] class ForeachUnsafeResult( + val sync: Try[Unit], + val async: List[Future[Any]] + ) + private def foreachUnsafe( + thunks: Iterable[() => Any] + ): ForeachUnsafeResult = { var errors = mutable.ListBuffer.empty[Throwable] + val async = mutable.ListBuffer.empty[Future[Any]] thunks.foreach { thunk => try { - thunk() + thunk() match { + case f: Future[_] => + async += f + case _ => + } } catch { case ex if NonFatal(ex) => errors += ex @@ -329,37 +384,45 @@ class MUnitRunner(val cls: Class[_ <: Suite], newInstance: () => Suite) head.addSuppressed(e) } } - scala.util.Failure(head) + new ForeachUnsafeResult(scala.util.Failure(head), Nil) case _ => - scala.util.Success(()) + new ForeachUnsafeResult(scala.util.Success(()), Nil) } } private def runHiddenTest( notifier: RunNotifier, name: String, - thunk: => Unit - ): Boolean = { + thunk: () => Any + ): Future[Boolean] = { try { - StackTraces.dropOutside(thunk) - true + StackTraces.dropOutside { + valueTransform(thunk) + .transformCompat { + case util.Success(value) => + util.Success(true) + case util.Failure(exception) => + fireFailedHiddenTest(notifier, name, exception) + util.Success(false) + } + } } catch { case ex: Throwable => - fireHiddenTest(notifier, name, ex) - false + fireFailedHiddenTest(notifier, name, ex) + Future.successful(false) } } - private def fireHiddenTest( + private def fireFailedHiddenTest( notifier: RunNotifier, name: String, ex: Throwable ): Unit = { - val test = new suite.Test(name, () => ???, Set.empty, Location.empty) + val test = new Test(name, () => ???, Set.empty, Location.empty) val description = createTestDescription(test) notifier.fireTestStarted(description) trimStackTrace(ex) - notifier.fireTestFailure(new Failure(description, ex)) + notifier.fireTestFailure(new Failure(description, rootCause(ex))) notifier.fireTestFinished(description) } private def trimStackTrace(ex: Throwable): Unit = { @@ -368,6 +431,13 @@ class MUnitRunner(val cls: Class[_ <: Suite], newInstance: () => Suite) } } + private def valueTransform(thunk: () => Any): Future[Any] = { + suite match { + case funSuite: FunSuite => funSuite.munitValueTransform(thunk()) + case _ => futureFromAny(thunk()) + } + } + } object MUnitRunner { private def ensureEligibleConstructor( diff --git a/munit/shared/src/main/scala/munit/Suite.scala b/munit/shared/src/main/scala/munit/Suite.scala index 03d4d052..c3eae8a0 100644 --- a/munit/shared/src/main/scala/munit/Suite.scala +++ b/munit/shared/src/main/scala/munit/Suite.scala @@ -2,6 +2,7 @@ package munit import org.junit.runner.RunWith import scala.concurrent.ExecutionContext +import scala.concurrent.Future /** * The base class for all test suites. @@ -11,10 +12,10 @@ import scala.concurrent.ExecutionContext abstract class Suite extends PlatformSuite { /** The value produced by test bodies. */ - type TestValue - final type Test = GenericTest[TestValue] - final type BeforeEach = GenericBeforeEach[TestValue] - final type AfterEach = GenericAfterEach[TestValue] + type TestValue = Future[Any] + final type Test = munit.Test + final type BeforeEach = munit.BeforeEach + final type AfterEach = munit.AfterEach /** The base class for all test suites */ def munitTests(): Seq[Test] @@ -28,31 +29,7 @@ abstract class Suite extends PlatformSuite { } def munitExecutionContext: ExecutionContext = parasiticExecutionContext - /** - * @param name The name of this fixture, used for displaying an error message if - * `beforeAll()` or `afterAll()` fail. - */ - abstract class Fixture[T](val fixtureName: String) { - - /** The value produced by this suite-local fixture that can be reused for all test cases. */ - def apply(): T - - /** Runs once before the test suite starts */ - def beforeAll(): Unit = () - - /** - * Runs before each individual test case. - * An error in this method aborts the test case. - */ - def beforeEach(context: BeforeEach): Unit = () - - /** Runs after each individual test case. */ - def afterEach(context: AfterEach): Unit = () - - /** Runs once after the test suite has finished, regardless if the tests failed or not. */ - def afterAll(): Unit = () - - } + type Fixture[T] = munit.Fixture[T] /** * Runs once before all test cases and before all suite-local fixtures are setup. diff --git a/munit/shared/src/main/scala/munit/SuiteTransforms.scala b/munit/shared/src/main/scala/munit/SuiteTransforms.scala index 1a60af08..26fa13db 100644 --- a/munit/shared/src/main/scala/munit/SuiteTransforms.scala +++ b/munit/shared/src/main/scala/munit/SuiteTransforms.scala @@ -54,7 +54,7 @@ trait SuiteTransforms { this: FunSuite => } else { onlySuite.map(t => if (t.tags(Only)) { - t.withBody[TestValue](() => + t.withBody(() => fail("'Only' tag is not allowed when `isCI=true`")(t.location) ) } else { diff --git a/munit/shared/src/main/scala/munit/GenericTest.scala b/munit/shared/src/main/scala/munit/Test.scala similarity index 65% rename from munit/shared/src/main/scala/munit/GenericTest.scala rename to munit/shared/src/main/scala/munit/Test.scala index f808abc6..91511694 100644 --- a/munit/shared/src/main/scala/munit/GenericTest.scala +++ b/munit/shared/src/main/scala/munit/Test.scala @@ -2,6 +2,7 @@ package munit import java.lang.annotation.Annotation import scala.collection.mutable +import scala.concurrent.Future /** * Metadata about a single test case. @@ -10,37 +11,37 @@ import scala.collection.mutable * @param tags the annotated tags for this test case. * @param location the file and line number where this test was defined. */ -class GenericTest[T]( +sealed class Test( val name: String, - val body: () => T, + val body: () => Future[Any], val tags: Set[Tag], val location: Location ) extends Serializable { - def this(name: String, body: () => T)(implicit loc: Location) = + def this(name: String, body: () => Future[Any])(implicit loc: Location) = this(name, body, Set.empty, loc) - def withName(newName: String): GenericTest[T] = + def withName(newName: String): Test = copy(name = newName) - def withBody[A](newBody: () => A): GenericTest[A] = + def withBody(newBody: () => Future[Any]): Test = copy(body = newBody) - def withTags(newTags: Set[Tag]): GenericTest[T] = + def withTags(newTags: Set[Tag]): Test = copy(tags = newTags) - def tag(newTag: Tag): GenericTest[T] = + def tag(newTag: Tag): Test = withTags(tags + newTag) - def withLocation(newLocation: Location): GenericTest[T] = + def withLocation(newLocation: Location): Test = copy(location = newLocation) - def withBodyMap[A](newBody: T => A): GenericTest[A] = - withBody[A](() => newBody(body())) + def withBodyMap[A](newBody: Future[Any] => Future[Any]): Test = + withBody(() => newBody(body())) private[this] def copy[A]( name: String = this.name, - body: () => A = this.body, + body: () => Future[Any] = this.body, tags: Set[Tag] = this.tags, location: Location = this.location - ): GenericTest[A] = { - new GenericTest(name, body, tags, location) + ): Test = { + new Test(name, body, tags, location) } - override def toString(): String = s"GenericTest($name, $tags, $location)" + override def toString(): String = s"Test($name, $tags, $location)" // NOTE(olafur): tests have reference equality because there's no reasonable // structural equality that we can use to compare the test body function. override def equals(obj: Any): Boolean = this.eq(obj.asInstanceOf[AnyRef]) diff --git a/munit/shared/src/main/scala/munit/TestTransforms.scala b/munit/shared/src/main/scala/munit/TestTransforms.scala index 3fda7824..f1d91cea 100644 --- a/munit/shared/src/main/scala/munit/TestTransforms.scala +++ b/munit/shared/src/main/scala/munit/TestTransforms.scala @@ -26,7 +26,7 @@ trait TestTransforms { this: FunSuite => } } catch { case NonFatal(e) => - test.withBody[TestValue](() => Future.failed(e)) + test.withBody(() => Future.failed(e)) } } diff --git a/munit/shared/src/main/scala/munit/UnitFixture.scala b/munit/shared/src/main/scala/munit/UnitFixture.scala new file mode 100644 index 00000000..46e4472b --- /dev/null +++ b/munit/shared/src/main/scala/munit/UnitFixture.scala @@ -0,0 +1,11 @@ +package munit + +/** + * Extend this class if you want to create a Fixture where all methods have `Unit` as the result type. + */ +abstract class UnitFixture[T](name: String) extends Fixture[T](name) { + override def beforeAll(): Unit = () + override def beforeEach(context: BeforeEach): Unit = () + override def afterEach(context: AfterEach): Unit = () + override def afterAll(): Unit = () +} diff --git a/munit/shared/src/main/scala/munit/package.scala b/munit/shared/src/main/scala/munit/package.scala index 3f25dc83..75c54da1 100644 --- a/munit/shared/src/main/scala/munit/package.scala +++ b/munit/shared/src/main/scala/munit/package.scala @@ -4,4 +4,13 @@ package object munit { val Flaky = new Tag("Flaky") val Fail = new Tag("Fail") val Slow = new Tag("Slow") + + @deprecated("use BeforeEach instead", "1.0.0") + type GenericBeforeEach[T] = BeforeEach + + @deprecated("use AfterEach instead", "1.0.0") + type GenericAfterEach[T] = AfterEach + + @deprecated("use Test instead", "1.0.0") + type GenericTest[T] = Test } diff --git a/tests/jvm/src/test/scala/munit/AsyncFixtureSuite.scala b/tests/jvm/src/test/scala/munit/AsyncFixtureSuite.scala new file mode 100644 index 00000000..170d6aa8 --- /dev/null +++ b/tests/jvm/src/test/scala/munit/AsyncFixtureSuite.scala @@ -0,0 +1,97 @@ +package munit + +import scala.concurrent.Promise +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import scala.concurrent.ExecutionContext +import java.util.concurrent.ScheduledExecutorService + +class AsyncFixtureSuite extends BaseSuite { + case class PromiseWrapper(promise: Promise[_]) + override def munitValueTransforms: List[ValueTransform] = + super.munitValueTransforms ++ List( + new ValueTransform( + "PromiseWrapper", + { case p: PromiseWrapper => + p.promise.future + } + ) + ) + class ScheduledMessage() extends Fixture[String]("AsyncFixture") { + val sh: ScheduledExecutorService = + Executors.newSingleThreadScheduledExecutor() + private var didBeforeAllEvaluateAsync = false + private var promise = Promise[String]() + private val timeout = 20 + def apply(): String = promise.future.value.get.get + override def beforeAll(): Any = { + val setBeforeAllBit = Promise[Unit]() + sh.schedule[Unit]( + () => { + didBeforeAllEvaluateAsync = true + setBeforeAllBit.success(()) + }, + timeout, + TimeUnit.MILLISECONDS + ) + PromiseWrapper(setBeforeAllBit) + } + override def beforeEach(context: BeforeEach): Any = { + assertEquals( + promise.future.value, + None, + "promise did not get reset from afterEach" + ) + assert( + didBeforeAllEvaluateAsync, + "beforeAll promise did not complete yet" + ) + sh.schedule[Unit]( + () => promise.success(s"beforeEach-${context.test.name}"), + timeout, + TimeUnit.MILLISECONDS + ) + PromiseWrapper(promise) + } + override def afterEach(context: AfterEach): Any = { + val resetPromise = Promise[Unit]() + sh.schedule[Unit]( + () => { + promise = Promise[String]() + resetPromise.success(()) + }, + timeout, + TimeUnit.MILLISECONDS + ) + PromiseWrapper(resetPromise) + } + override def afterAll(): Any = { + val shutdownPromise = Promise[Unit]() + ExecutionContext.global.execute(() => { + Thread.sleep(timeout) + val runningJobs = sh.shutdownNow() + assert(runningJobs.isEmpty(), runningJobs) + shutdownPromise.success(()) + }) + PromiseWrapper(shutdownPromise) + } + } + val message = new ScheduledMessage() + val latest: Fixture[Unit] = new Fixture[Unit]("latest") { + def apply(): Unit = () + override def afterAll(): Any = { + assert( + message.sh.isShutdown(), + "message.afterAll did not complete yet. " + + "We may want to remove this assertion in the future if we allow fixtures to load in parallel." + ) + } + } + override def munitFixtures: Seq[Fixture[_]] = List(message, latest) + + 1.to(3).foreach { i => + test(s"test-$i") { + assertEquals(message(), s"beforeEach-test-$i") + } + } +} diff --git a/tests/jvm/src/test/scala/munit/AsyncFixtureOrderSuite.scala b/tests/jvm/src/test/scala/munit/AsyncFunFixtureOrderSuite.scala similarity index 95% rename from tests/jvm/src/test/scala/munit/AsyncFixtureOrderSuite.scala rename to tests/jvm/src/test/scala/munit/AsyncFunFixtureOrderSuite.scala index c2ca2b31..c6eda50b 100644 --- a/tests/jvm/src/test/scala/munit/AsyncFixtureOrderSuite.scala +++ b/tests/jvm/src/test/scala/munit/AsyncFunFixtureOrderSuite.scala @@ -3,7 +3,7 @@ package munit import scala.concurrent.Future import scala.concurrent.Promise -class AsyncFixtureOrderSuite extends FunSuite { +class AsyncFunFixtureOrderSuite extends FunSuite { val latch: Promise[Unit] = Promise[Unit] var completedFromTest: Option[Boolean] = None var completedFromTeardown: Option[Boolean] = None diff --git a/tests/shared/src/main/scala/munit/AsyncFixtureFrameworkSuite.scala b/tests/shared/src/main/scala/munit/AsyncFunFixtureFrameworkSuite.scala similarity index 94% rename from tests/shared/src/main/scala/munit/AsyncFixtureFrameworkSuite.scala rename to tests/shared/src/main/scala/munit/AsyncFunFixtureFrameworkSuite.scala index b8af92ca..2d8a1f39 100644 --- a/tests/shared/src/main/scala/munit/AsyncFixtureFrameworkSuite.scala +++ b/tests/shared/src/main/scala/munit/AsyncFunFixtureFrameworkSuite.scala @@ -2,7 +2,7 @@ package munit import scala.concurrent.Future -class AsyncFixtureFrameworkSuite extends FunSuite { +class AsyncFunFixtureFrameworkSuite extends FunSuite { val failingSetup: FunFixture[Unit] = FunFixture.async[Unit]( _ => Future.failed(new Error("failure in setup")), _ => Future.successful(()) @@ -49,9 +49,9 @@ class AsyncFixtureFrameworkSuite extends FunSuite { .test("fail when even more nested mapped teardown fails") { _ => () } } -object AsyncFixtureFrameworkSuite +object AsyncFunFixtureFrameworkSuite extends FrameworkTest( - classOf[AsyncFixtureFrameworkSuite], + classOf[AsyncFunFixtureFrameworkSuite], """|==> failure munit.AsyncFixtureFrameworkSuite.fail when setup fails - failure in setup |==> failure munit.AsyncFixtureFrameworkSuite.fail when teardown fails - failure in teardown |==> failure munit.AsyncFixtureFrameworkSuite.fail when test and teardown fail - /scala/munit/AsyncFixtureFrameworkSuite.scala:28 failure in test diff --git a/tests/shared/src/main/scala/munit/FixtureFrameworkSuite.scala b/tests/shared/src/main/scala/munit/FixtureOrderFrameworkSuite.scala similarity index 94% rename from tests/shared/src/main/scala/munit/FixtureFrameworkSuite.scala rename to tests/shared/src/main/scala/munit/FixtureOrderFrameworkSuite.scala index c6976ed5..4551db56 100644 --- a/tests/shared/src/main/scala/munit/FixtureFrameworkSuite.scala +++ b/tests/shared/src/main/scala/munit/FixtureOrderFrameworkSuite.scala @@ -1,6 +1,6 @@ package munit -class FixtureFrameworkSuite extends FunSuite { +class FixtureOrderFrameworkSuite extends FunSuite { def println(msg: String): Unit = TestingConsole.out.println(msg) private def fixture(name: String) = new Fixture[Int](name) { def apply(): Int = 1 @@ -42,9 +42,9 @@ class FixtureFrameworkSuite extends FunSuite { } } -object FixtureFrameworkSuite +object FixtureOrderFrameworkSuite extends FrameworkTest( - classOf[FixtureFrameworkSuite], + classOf[FixtureOrderFrameworkSuite], """|munit.FixtureFrameworkSuite: |beforeAll(ad-hoc) |beforeAll(a) @@ -53,29 +53,29 @@ object FixtureFrameworkSuite |beforeEach(a, 1) |beforeEach(b, 1) |test(1) + |afterEach(ad-hoc, 1) |afterEach(a, 1) |afterEach(b, 1) - |afterEach(ad-hoc, 1) | + 1 |beforeEach(ad-hoc, 2) |beforeEach(a, 2) |beforeEach(b, 2) |test(2) + |afterEach(ad-hoc, 2) |afterEach(a, 2) |afterEach(b, 2) - |afterEach(ad-hoc, 2) | + 2 |beforeEach(ad-hoc, 3) |beforeEach(a, 3) |beforeEach(b, 3) |test(3) + |afterEach(ad-hoc, 3) |afterEach(a, 3) |afterEach(b, 3) - |afterEach(ad-hoc, 3) | + 3 + |afterAll(ad-hoc) |afterAll(a) |afterAll(b) - |afterAll(ad-hoc) |""".stripMargin, format = StdoutFormat ) diff --git a/tests/shared/src/main/scala/munit/ValueTransformCrashFrameworkSuite.scala b/tests/shared/src/main/scala/munit/ValueTransformCrashFrameworkSuite.scala index 6eaf843e..8d6f89c7 100644 --- a/tests/shared/src/main/scala/munit/ValueTransformCrashFrameworkSuite.scala +++ b/tests/shared/src/main/scala/munit/ValueTransformCrashFrameworkSuite.scala @@ -2,10 +2,12 @@ package munit class ValueTransformCrashFrameworkSuite extends munit.FunSuite { override val munitValueTransforms: List[ValueTransform] = List( - new ValueTransform("boom", { case test => ??? }) + new ValueTransform("boom", { case "test-body" => ??? }) ) - test("hello") {} + test("hello") { + "test-body" + } } object ValueTransformCrashFrameworkSuite extends FrameworkTest( diff --git a/tests/shared/src/test/scala/munit/FrameworkSuite.scala b/tests/shared/src/test/scala/munit/FrameworkSuite.scala index 8ef72ad7..573af70c 100644 --- a/tests/shared/src/test/scala/munit/FrameworkSuite.scala +++ b/tests/shared/src/test/scala/munit/FrameworkSuite.scala @@ -10,7 +10,7 @@ class FrameworkSuite extends BaseFrameworkSuite { FailSuiteFrameworkSuite, TestNameFrameworkSuite, ScalaVersionFrameworkSuite, - FixtureFrameworkSuite, + FixtureOrderFrameworkSuite, TagsIncludeFramweworkSuite, TagsIncludeExcludeFramweworkSuite, TagsExcludeFramweworkSuite, @@ -21,7 +21,7 @@ class FrameworkSuite extends BaseFrameworkSuite { ValueTransformCrashFrameworkSuite, ValueTransformFrameworkSuite, ScalaCheckFrameworkSuite, - AsyncFixtureFrameworkSuite, + AsyncFunFixtureFrameworkSuite, AsyncFixtureTeardownFrameworkSuite, DuplicateNameFrameworkSuite, FullStackTraceFrameworkSuite, diff --git a/tests/shared/src/test/scala/munit/SuiteLocalFixtureSuite.scala b/tests/shared/src/test/scala/munit/SuiteLocalFixtureSuite.scala index 16c8c476..306731dc 100644 --- a/tests/shared/src/test/scala/munit/SuiteLocalFixtureSuite.scala +++ b/tests/shared/src/test/scala/munit/SuiteLocalFixtureSuite.scala @@ -8,9 +8,11 @@ class SuiteLocalFixtureSuite extends FunSuite { n } override def beforeAll(): Unit = { + assertEquals(n, 0) n = 1 } override def afterAll(): Unit = { + assertEquals(n, 17) n = -11 } } @@ -32,7 +34,7 @@ class SuiteLocalFixtureSuite extends FunSuite { } override def afterAll(): Unit = { - assertEquals(counter(), -10) + assertEquals(counter(), 17) } 1.to(5).foreach { i => diff --git a/tests/shared/src/test/scala/munit/TestLocalFixtureSuite.scala b/tests/shared/src/test/scala/munit/TestLocalFixtureSuite.scala index 7eb9cce7..88723142 100644 --- a/tests/shared/src/test/scala/munit/TestLocalFixtureSuite.scala +++ b/tests/shared/src/test/scala/munit/TestLocalFixtureSuite.scala @@ -13,7 +13,7 @@ class TestLocalFixtureSuite extends FunSuite { } val name2 = name - override def afterEach(context: GenericAfterEach[TestValue]): Unit = { + override def afterEach(context: AfterEach): Unit = { assertEquals(name(), context.test.name + "-after") } diff --git a/website/.tool-versions b/website/.tool-versions new file mode 100644 index 00000000..0288186e --- /dev/null +++ b/website/.tool-versions @@ -0,0 +1 @@ +yarn 1.22.4 diff --git a/website/blog/2020-02-01-hello-world.md b/website/blog/2020-02-01-hello-world.md index fe31ccd6..3594ebb2 100644 --- a/website/blog/2020-02-01-hello-world.md +++ b/website/blog/2020-02-01-hello-world.md @@ -65,49 +65,6 @@ class MySuite extends munit.FunSuite { Check out the [getting started guide](https://scalameta.org/munit/docs/getting-started.html). -## Tests as values - -If you know how to write normal Scala programs you should feel comfortable -reasoning about how MUnit works. - -Internally, a core MUnit data structure is `GenericTest[T]`, which represents a -single test case and is roughly defined like this. - -```scala -case class GenericTest[T]( - name: String, - body: () => T, - tags: Set[Tag], - location: Location -) -abstract class Suite { - type TestValue - type Test = GenericTest[TestValue] - def munitTests(): Seq[Test] -} -``` - -A test suite returns a `Seq[Test]`, which you as a user can generate and -abstract over any way you like. - -Importantly, MUnit test cases are not discovered via runtime reflection like in -JUnit and MUnit test cases are not generated via macros like in utest. - -MUnit provides a high-level API to write tests in a ScalaTest-inspired -`FunSuite` syntax where the type parameter for `GenericTest[T]` is defined as -`Future[Any]`. - -```scala -abstract class FunSuite extends Suite { - type TestValue = Future[Any] -} -``` - -For common usage of MUnit you are not expected to write raw -`GenericTest[T](...)` expressions but knowing this underlying data model helps -you implement features like test retries, disabling tests based on dynamic -conditions, enforce stricter type safety and more. - ## Rich filtering capabilities Using tags, MUnit provides a extensible way to disable/enable tests based on From 995c22bcbc03cacbc3c3c2410f5695acf0c042a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93lafur=20P=C3=A1ll=20Geirsson?= Date: Mon, 11 Oct 2021 09:20:47 +0200 Subject: [PATCH 02/10] Make tests pass A bug in Scala 2.11/2.12 made `TimeoutSuite` fail with the new async fixtures. This commit changes how we implement `munitTimeout` to race two futures instead of calling `Await.result`. --- .../main/scala/munit/ScalaCheckSuite.scala | 2 +- .../scala/munit/internal/PlatformCompat.scala | 7 ++- .../scala/munit/internal/PlatformCompat.scala | 32 +++++++++++-- .../scala/munit/internal/PlatformCompat.scala | 7 ++- .../src/main/scala/munit/FunSuite.scala | 2 +- .../src/main/scala/munit/MUnitRunner.scala | 45 ++++++++++--------- munit/shared/src/main/scala/munit/Test.scala | 2 +- .../src/main/scala/munit/TestTransforms.scala | 2 +- .../test/scala/munit/AsyncFixtureSuite.scala | 10 ++--- .../src/test/scala/munit/TimeoutSuite.scala | 4 +- .../munit/AsyncFunFixtureFrameworkSuite.scala | 14 +++--- .../munit/FixtureOrderFrameworkSuite.scala | 2 +- 12 files changed, 84 insertions(+), 45 deletions(-) diff --git a/munit-scalacheck/shared/src/main/scala/munit/ScalaCheckSuite.scala b/munit-scalacheck/shared/src/main/scala/munit/ScalaCheckSuite.scala index 58b2bac3..a0d3d4b7 100644 --- a/munit-scalacheck/shared/src/main/scala/munit/ScalaCheckSuite.scala +++ b/munit-scalacheck/shared/src/main/scala/munit/ScalaCheckSuite.scala @@ -41,7 +41,7 @@ trait ScalaCheckSuite extends FunSuite { new TestTransform( "ScalaCheck Prop", t => { - t.withBodyMap[TestValue]( + t.withBodyMap( _.transformCompat { case Success(prop: Prop) => propToTry(prop, t) case r => r diff --git a/munit/js/src/main/scala/munit/internal/PlatformCompat.scala b/munit/js/src/main/scala/munit/internal/PlatformCompat.scala index 700088ad..5fff9ba7 100644 --- a/munit/js/src/main/scala/munit/internal/PlatformCompat.scala +++ b/munit/js/src/main/scala/munit/internal/PlatformCompat.scala @@ -9,6 +9,7 @@ import sbt.testing.EventHandler import sbt.testing.Logger import scala.concurrent.Promise import scala.concurrent.duration.Duration +import scala.concurrent.ExecutionContext object PlatformCompat { def executeAsync( @@ -20,7 +21,11 @@ object PlatformCompat { task.execute(eventHandler, loggers, _ => p.success(())) p.future } - def waitAtMost[T](future: Future[T], duration: Duration): Future[T] = { + def waitAtMost[T]( + future: Future[T], + duration: Duration, + ec: ExecutionContext + ): Future[T] = { future } diff --git a/munit/jvm/src/main/scala/munit/internal/PlatformCompat.scala b/munit/jvm/src/main/scala/munit/internal/PlatformCompat.scala index 2f46d7d8..c93c9c9c 100644 --- a/munit/jvm/src/main/scala/munit/internal/PlatformCompat.scala +++ b/munit/jvm/src/main/scala/munit/internal/PlatformCompat.scala @@ -4,11 +4,16 @@ import scala.concurrent.Future import sbt.testing.Task import sbt.testing.EventHandler import sbt.testing.Logger -import scala.concurrent.Await import scala.concurrent.duration.Duration -import scala.util.Try +import scala.concurrent.BlockContext +import java.util.concurrent.Executors +import scala.concurrent.Promise +import scala.concurrent.ExecutionContext +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException object PlatformCompat { + private val sh = Executors.newSingleThreadScheduledExecutor() def executeAsync( task: Task, eventHandler: EventHandler, @@ -17,8 +22,27 @@ object PlatformCompat { task.execute(eventHandler, loggers) Future.successful(()) } - def waitAtMost[T](future: Future[T], duration: Duration): Future[T] = { - Future.fromTry(Try(Await.result(future, duration))) + private[this] val _blockContext = new ThreadLocal[BlockContext]() + def waitAtMost[T]( + future: Future[T], + duration: Duration, + ec: ExecutionContext + ): Future[T] = { + val onComplete = Promise[T]() + var onCancel: () => Unit = () => () + future.onComplete { result => + onComplete.tryComplete(result) + }(ec) + val timeout = sh.schedule[Unit]( + () => + onComplete.tryFailure( + new TimeoutException(s"test timed out after $duration") + ), + duration.toMillis, + TimeUnit.MILLISECONDS + ) + onCancel = () => timeout.cancel(false) + onComplete.future } def isIgnoreSuite(cls: Class[_]): Boolean = diff --git a/munit/native/src/main/scala/munit/internal/PlatformCompat.scala b/munit/native/src/main/scala/munit/internal/PlatformCompat.scala index 464b3067..c93d87e3 100644 --- a/munit/native/src/main/scala/munit/internal/PlatformCompat.scala +++ b/munit/native/src/main/scala/munit/internal/PlatformCompat.scala @@ -8,6 +8,7 @@ import sbt.testing.Task import sbt.testing.EventHandler import sbt.testing.Logger import scala.concurrent.duration.Duration +import scala.concurrent.ExecutionContext object PlatformCompat { def executeAsync( @@ -18,7 +19,11 @@ object PlatformCompat { task.execute(eventHandler, loggers) Future.successful(()) } - def waitAtMost[T](future: Future[T], duration: Duration): Future[T] = { + def waitAtMost[T]( + future: Future[T], + duration: Duration, + ec: ExecutionContext + ): Future[T] = { future } diff --git a/munit/shared/src/main/scala/munit/FunSuite.scala b/munit/shared/src/main/scala/munit/FunSuite.scala index 5b6e6725..b9bdd6b7 100644 --- a/munit/shared/src/main/scala/munit/FunSuite.scala +++ b/munit/shared/src/main/scala/munit/FunSuite.scala @@ -46,6 +46,6 @@ abstract class FunSuite def munitTimeout: Duration = new FiniteDuration(30, TimeUnit.SECONDS) private final def waitForCompletion[T](f: Future[T]) = - PlatformCompat.waitAtMost(f, munitTimeout) + PlatformCompat.waitAtMost(f, munitTimeout, munitExecutionContext) } diff --git a/munit/shared/src/main/scala/munit/MUnitRunner.scala b/munit/shared/src/main/scala/munit/MUnitRunner.scala index 7f11ea39..9992f530 100644 --- a/munit/shared/src/main/scala/munit/MUnitRunner.scala +++ b/munit/shared/src/main/scala/munit/MUnitRunner.scala @@ -120,28 +120,27 @@ class MUnitRunner(val cls: Class[_ <: Suite], newInstance: () => Suite) def runAsync(notifier: RunNotifier): Future[Unit] = { val description = getDescription() notifier.fireTestSuiteStarted(description) - try { - runAll(notifier) - } catch { - case ex: Throwable => - Future.successful( - fireFailedHiddenTest(notifier, "expected error running tests", ex) + runAll(notifier) + .transformCompat[Unit](result => { + result.failed.foreach(ex => + fireFailedHiddenTest(notifier, "unexpected error running tests", ex) ) - } finally { - notifier.fireTestSuiteFinished(description) - } + notifier.fireTestSuiteFinished(description) + util.Success(()) + }) } private def runTests( notifier: RunNotifier ): Future[List[Try[Boolean]]] = { - runAsyncTestsSynchronously( + sequenceFutures( munitTests.iterator.map(t => runTest(notifier, t)) ) } - private def runAsyncTestsSynchronously[A]( - tests: Iterator[Future[A]] + // Similar `Future.sequence` but with cleaner stack traces for non-async code. + private def sequenceFutures[A]( + futures: Iterator[Future[A]] ): Future[List[Try[A]]] = { def loop( it: Iterator[Future[A]], @@ -163,7 +162,14 @@ class MUnitRunner(val cls: Class[_ <: Suite], newInstance: () => Suite) }) } } - loop(tests, mutable.ListBuffer.empty) + loop(futures, mutable.ListBuffer.empty) + } + + private def munitTimeout(): Option[Duration] = { + suite match { + case funSuite: FunSuite => Some(funSuite.munitTimeout) + case _ => None + } } private def runAll(notifier: RunNotifier): Future[Unit] = { @@ -193,7 +199,7 @@ class MUnitRunner(val cls: Class[_ <: Suite], newInstance: () => Suite) private def runBeforeAll(notifier: RunNotifier): Future[BeforeAllResult] = { val result: Future[List[Try[(Fixture[_], Boolean)]]] = - runAsyncTestsSynchronously( + sequenceFutures( munitFixtures.iterator.map(f => runHiddenTest( notifier, @@ -216,7 +222,7 @@ class MUnitRunner(val cls: Class[_ <: Suite], newInstance: () => Suite) notifier: RunNotifier, beforeAll: BeforeAllResult ): Future[Unit] = { - runAsyncTestsSynchronously[Boolean]( + sequenceFutures[Boolean]( beforeAll.loadedFixtures.iterator.map(f => runHiddenTest( notifier, @@ -237,7 +243,7 @@ class MUnitRunner(val cls: Class[_ <: Suite], newInstance: () => Suite) ): Future[BeforeAllResult] = { val context = new BeforeEach(test) val fixtures = mutable.ListBuffer.empty[Fixture[_]] - runAsyncTestsSynchronously( + sequenceFutures( munitFixtures.iterator.map(f => valueTransform(() => f.beforeEach(context)).map(_ => f) ) @@ -254,7 +260,7 @@ class MUnitRunner(val cls: Class[_ <: Suite], newInstance: () => Suite) fixtures: List[Fixture[_]] ): Future[Unit] = { val context = new AfterEach(test) - runAsyncTestsSynchronously( + sequenceFutures( fixtures.iterator.map(f => valueTransform(() => f.afterEach(context))) ).map(_ => ()) } @@ -335,9 +341,8 @@ class MUnitRunner(val cls: Class[_ <: Suite], newInstance: () => Suite) StackTraces .dropOutside(test.body()) .transformWithCompat(result => - runAfterEach(test, beforeEach.loadedFixtures).transform(_ => - result - ) + runAfterEach(test, beforeEach.loadedFixtures) + .transformCompat(_ => result) ) case error :: errors => errors.foreach(err => error.addSuppressed(err)) diff --git a/munit/shared/src/main/scala/munit/Test.scala b/munit/shared/src/main/scala/munit/Test.scala index 91511694..d3e25492 100644 --- a/munit/shared/src/main/scala/munit/Test.scala +++ b/munit/shared/src/main/scala/munit/Test.scala @@ -30,7 +30,7 @@ sealed class Test( def withLocation(newLocation: Location): Test = copy(location = newLocation) - def withBodyMap[A](newBody: Future[Any] => Future[Any]): Test = + def withBodyMap(newBody: Future[Any] => Future[Any]): Test = withBody(() => newBody(body())) private[this] def copy[A]( diff --git a/munit/shared/src/main/scala/munit/TestTransforms.scala b/munit/shared/src/main/scala/munit/TestTransforms.scala index f1d91cea..5e17fcde 100644 --- a/munit/shared/src/main/scala/munit/TestTransforms.scala +++ b/munit/shared/src/main/scala/munit/TestTransforms.scala @@ -35,7 +35,7 @@ trait TestTransforms { this: FunSuite => "fail", { t => if (t.tags(Fail)) { - t.withBodyMap[TestValue]( + t.withBodyMap( _.transformCompat { case Success(value) => Failure( diff --git a/tests/jvm/src/test/scala/munit/AsyncFixtureSuite.scala b/tests/jvm/src/test/scala/munit/AsyncFixtureSuite.scala index 170d6aa8..f414b0e1 100644 --- a/tests/jvm/src/test/scala/munit/AsyncFixtureSuite.scala +++ b/tests/jvm/src/test/scala/munit/AsyncFixtureSuite.scala @@ -7,7 +7,7 @@ import scala.concurrent.ExecutionContext import java.util.concurrent.ScheduledExecutorService class AsyncFixtureSuite extends BaseSuite { - case class PromiseWrapper(promise: Promise[_]) + case class PromiseWrapper(name: String, promise: Promise[_]) override def munitValueTransforms: List[ValueTransform] = super.munitValueTransforms ++ List( new ValueTransform( @@ -34,7 +34,7 @@ class AsyncFixtureSuite extends BaseSuite { timeout, TimeUnit.MILLISECONDS ) - PromiseWrapper(setBeforeAllBit) + PromiseWrapper("beforeAll", setBeforeAllBit) } override def beforeEach(context: BeforeEach): Any = { assertEquals( @@ -51,7 +51,7 @@ class AsyncFixtureSuite extends BaseSuite { timeout, TimeUnit.MILLISECONDS ) - PromiseWrapper(promise) + PromiseWrapper("beforeEach", promise) } override def afterEach(context: AfterEach): Any = { val resetPromise = Promise[Unit]() @@ -63,7 +63,7 @@ class AsyncFixtureSuite extends BaseSuite { timeout, TimeUnit.MILLISECONDS ) - PromiseWrapper(resetPromise) + PromiseWrapper("afterEach", resetPromise) } override def afterAll(): Any = { val shutdownPromise = Promise[Unit]() @@ -73,7 +73,7 @@ class AsyncFixtureSuite extends BaseSuite { assert(runningJobs.isEmpty(), runningJobs) shutdownPromise.success(()) }) - PromiseWrapper(shutdownPromise) + PromiseWrapper("afterAll", shutdownPromise) } } val message = new ScheduledMessage() diff --git a/tests/jvm/src/test/scala/munit/TimeoutSuite.scala b/tests/jvm/src/test/scala/munit/TimeoutSuite.scala index 2186aa2f..7d1e6768 100644 --- a/tests/jvm/src/test/scala/munit/TimeoutSuite.scala +++ b/tests/jvm/src/test/scala/munit/TimeoutSuite.scala @@ -7,9 +7,9 @@ import scala.concurrent.duration.FiniteDuration class TimeoutSuite extends munit.FunSuite { override val munitTimeout: FiniteDuration = Duration(100, "ms") - test("slow".fail) { + test("infinite-loop".fail) { Future { - Thread.sleep(1000) + while (true) {} } } test("fast") { diff --git a/tests/shared/src/main/scala/munit/AsyncFunFixtureFrameworkSuite.scala b/tests/shared/src/main/scala/munit/AsyncFunFixtureFrameworkSuite.scala index 2d8a1f39..83697faa 100644 --- a/tests/shared/src/main/scala/munit/AsyncFunFixtureFrameworkSuite.scala +++ b/tests/shared/src/main/scala/munit/AsyncFunFixtureFrameworkSuite.scala @@ -52,15 +52,15 @@ class AsyncFunFixtureFrameworkSuite extends FunSuite { object AsyncFunFixtureFrameworkSuite extends FrameworkTest( classOf[AsyncFunFixtureFrameworkSuite], - """|==> failure munit.AsyncFixtureFrameworkSuite.fail when setup fails - failure in setup - |==> failure munit.AsyncFixtureFrameworkSuite.fail when teardown fails - failure in teardown - |==> failure munit.AsyncFixtureFrameworkSuite.fail when test and teardown fail - /scala/munit/AsyncFixtureFrameworkSuite.scala:28 failure in test + """|==> failure munit.AsyncFunFixtureFrameworkSuite.fail when setup fails - failure in setup + |==> failure munit.AsyncFunFixtureFrameworkSuite.fail when teardown fails - failure in teardown + |==> failure munit.AsyncFunFixtureFrameworkSuite.fail when test and teardown fail - /scala/munit/AsyncFunFixtureFrameworkSuite.scala:28 failure in test |27: failingTeardown.test("fail when test and teardown fail") { _ => |28: fail("failure in test") |29: } - |==> failure munit.AsyncFixtureFrameworkSuite.fail when mapped setup fails - failure in setup - |==> failure munit.AsyncFixtureFrameworkSuite.fail when even more nested mapped setup fails - failure in setup - |==> failure munit.AsyncFixtureFrameworkSuite.fail when mapped teardown fails - failure in teardown - |==> failure munit.AsyncFixtureFrameworkSuite.fail when even more nested mapped teardown fails - failure in teardown + |==> failure munit.AsyncFunFixtureFrameworkSuite.fail when mapped setup fails - failure in setup + |==> failure munit.AsyncFunFixtureFrameworkSuite.fail when even more nested mapped setup fails - failure in setup + |==> failure munit.AsyncFunFixtureFrameworkSuite.fail when mapped teardown fails - failure in teardown + |==> failure munit.AsyncFunFixtureFrameworkSuite.fail when even more nested mapped teardown fails - failure in teardown |""".stripMargin ) diff --git a/tests/shared/src/main/scala/munit/FixtureOrderFrameworkSuite.scala b/tests/shared/src/main/scala/munit/FixtureOrderFrameworkSuite.scala index 4551db56..ef1fdcd0 100644 --- a/tests/shared/src/main/scala/munit/FixtureOrderFrameworkSuite.scala +++ b/tests/shared/src/main/scala/munit/FixtureOrderFrameworkSuite.scala @@ -45,7 +45,7 @@ class FixtureOrderFrameworkSuite extends FunSuite { object FixtureOrderFrameworkSuite extends FrameworkTest( classOf[FixtureOrderFrameworkSuite], - """|munit.FixtureFrameworkSuite: + """|munit.FixtureOrderFrameworkSuite: |beforeAll(ad-hoc) |beforeAll(a) |beforeAll(b) From a9c06df5dfdc8a51f61f2338bf4a964ff3479429 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93lafur=20P=C3=A1ll=20Geirsson?= Date: Mon, 11 Oct 2021 10:18:45 +0200 Subject: [PATCH 03/10] Add MiMa excludes for breaking changes --- build.sbt | 24 ++++++++++++++++--- .../scala/munit/internal/PlatformCompat.scala | 1 + .../scala/munit/internal/PlatformCompat.scala | 7 ++++++ .../src/test/scala/munit/TimeoutSuite.scala | 11 ++++++++- 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/build.sbt b/build.sbt index 541ac1ef..9e07406b 100644 --- a/build.sbt +++ b/build.sbt @@ -1,6 +1,4 @@ -import com.typesafe.tools.mima.core.DirectMissingMethodProblem -import com.typesafe.tools.mima.core.ProblemFilters -import com.typesafe.tools.mima.core.MissingTypesProblem +import com.typesafe.tools.mima.core._ import sbtcrossproject.CrossPlugin.autoImport.crossProject import sbtcrossproject.CrossPlugin.autoImport.CrossType import scala.collection.mutable @@ -85,6 +83,26 @@ lazy val mimaEnable: List[Def.Setting[_]] = List( ), ProblemFilters.exclude[DirectMissingMethodProblem]( "munit.internal.junitinterface.JUnitComputer.this" + ), + // Known breaking changes for MUnit v1 + ProblemFilters.exclude[IncompatibleMethTypeProblem]( + "munit.FunSuite.munitTestTransform" + ), + ProblemFilters.exclude[MissingClassProblem]("munit.GenericAfterEach"), + ProblemFilters.exclude[MissingClassProblem]("munit.GenericBeforeEach"), + ProblemFilters.exclude[MissingClassProblem]("munit.GenericTest"), + ProblemFilters.exclude[DirectMissingMethodProblem]( + "munit.MUnitRunner.createTestDescription" + ), + ProblemFilters.exclude[IncompatibleMethTypeProblem]( + "munit.Suite.beforeEach" + ), + ProblemFilters.exclude[IncompatibleMethTypeProblem]( + "munit.Suite.afterEach" + ), + ProblemFilters.exclude[MissingClassProblem]("munit.Suite$Fixture"), + ProblemFilters.exclude[IncompatibleMethTypeProblem]( + "munit.TestTransforms#TestTransform.apply" ) ), mimaPreviousArtifacts := { diff --git a/munit/js/src/main/scala/munit/internal/PlatformCompat.scala b/munit/js/src/main/scala/munit/internal/PlatformCompat.scala index 5fff9ba7..fa16b03d 100644 --- a/munit/js/src/main/scala/munit/internal/PlatformCompat.scala +++ b/munit/js/src/main/scala/munit/internal/PlatformCompat.scala @@ -21,6 +21,7 @@ object PlatformCompat { task.execute(eventHandler, loggers, _ => p.success(())) p.future } + def waitAtMost[T]( future: Future[T], duration: Duration, diff --git a/munit/jvm/src/main/scala/munit/internal/PlatformCompat.scala b/munit/jvm/src/main/scala/munit/internal/PlatformCompat.scala index c93c9c9c..a05906af 100644 --- a/munit/jvm/src/main/scala/munit/internal/PlatformCompat.scala +++ b/munit/jvm/src/main/scala/munit/internal/PlatformCompat.scala @@ -23,6 +23,13 @@ object PlatformCompat { Future.successful(()) } private[this] val _blockContext = new ThreadLocal[BlockContext]() + @deprecated("use the overload with an explicit ExecutionContext", "1.0.0") + def waitAtMost[T]( + future: Future[T], + duration: Duration + ): Future[T] = { + waitAtMost(future, duration, ExecutionContext.global) + } def waitAtMost[T]( future: Future[T], duration: Duration, diff --git a/tests/jvm/src/test/scala/munit/TimeoutSuite.scala b/tests/jvm/src/test/scala/munit/TimeoutSuite.scala index 7d1e6768..619e9b59 100644 --- a/tests/jvm/src/test/scala/munit/TimeoutSuite.scala +++ b/tests/jvm/src/test/scala/munit/TimeoutSuite.scala @@ -9,7 +9,16 @@ class TimeoutSuite extends munit.FunSuite { override val munitTimeout: FiniteDuration = Duration(100, "ms") test("infinite-loop".fail) { Future { - while (true) {} + while (true) { + def fib(n: Int): Int = { + if (n < 1) 0 + else if (n == 1) n + else fib(n - 1) + fib(n - 2) + } + // Some computationally intensive calculation + 1.to(1000).foreach(i => fib(i)) + println("Loop") + } } } test("fast") { From cfc64be03e5ebc141b9475b4a7614288921d801f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93lafur=20P=C3=A1ll=20Geirsson?= Date: Mon, 11 Oct 2021 10:32:00 +0200 Subject: [PATCH 04/10] Cleanup code --- docs/fixtures.md | 2 +- munit/jvm/src/main/scala/munit/internal/PlatformCompat.scala | 2 -- munit/shared/src/main/scala/munit/AfterEach.scala | 5 +++++ munit/shared/src/main/scala/munit/BeforeEach.scala | 4 ---- munit/shared/src/main/scala/munit/Fixture.scala | 3 +-- munit/shared/src/main/scala/munit/FutureFixture.scala | 2 +- munit/shared/src/main/scala/munit/Suite.scala | 5 ++--- munit/shared/src/main/scala/munit/Test.scala | 2 +- tests/jvm/src/test/scala/munit/TimeoutSuite.scala | 5 +++++ 9 files changed, 16 insertions(+), 14 deletions(-) create mode 100644 munit/shared/src/main/scala/munit/AfterEach.scala diff --git a/docs/fixtures.md b/docs/fixtures.md index 42351598..25d97296 100644 --- a/docs/fixtures.md +++ b/docs/fixtures.md @@ -125,7 +125,7 @@ class MySuite extends munit.FunSuite { Return a `Future`-like value from the methods `beforeAll`, `beforeEach`, `afterEach` and `afterAll` to make an asynchronous fixture. By default, only `Future[_]` values are recognized. Override `munitValueTransforms` to add -support for writing async fixture with other `Future`-like types, see +support for other `Future`-like types, see [declare async tests](tests.md#declare-async-test) for more details. ```scala mdoc:reset diff --git a/munit/jvm/src/main/scala/munit/internal/PlatformCompat.scala b/munit/jvm/src/main/scala/munit/internal/PlatformCompat.scala index a05906af..38684e79 100644 --- a/munit/jvm/src/main/scala/munit/internal/PlatformCompat.scala +++ b/munit/jvm/src/main/scala/munit/internal/PlatformCompat.scala @@ -5,7 +5,6 @@ import sbt.testing.Task import sbt.testing.EventHandler import sbt.testing.Logger import scala.concurrent.duration.Duration -import scala.concurrent.BlockContext import java.util.concurrent.Executors import scala.concurrent.Promise import scala.concurrent.ExecutionContext @@ -22,7 +21,6 @@ object PlatformCompat { task.execute(eventHandler, loggers) Future.successful(()) } - private[this] val _blockContext = new ThreadLocal[BlockContext]() @deprecated("use the overload with an explicit ExecutionContext", "1.0.0") def waitAtMost[T]( future: Future[T], diff --git a/munit/shared/src/main/scala/munit/AfterEach.scala b/munit/shared/src/main/scala/munit/AfterEach.scala new file mode 100644 index 00000000..74b8d234 --- /dev/null +++ b/munit/shared/src/main/scala/munit/AfterEach.scala @@ -0,0 +1,5 @@ +package munit + +class AfterEach( + val test: Test +) extends Serializable diff --git a/munit/shared/src/main/scala/munit/BeforeEach.scala b/munit/shared/src/main/scala/munit/BeforeEach.scala index 91abf349..99c8406b 100644 --- a/munit/shared/src/main/scala/munit/BeforeEach.scala +++ b/munit/shared/src/main/scala/munit/BeforeEach.scala @@ -3,7 +3,3 @@ package munit class BeforeEach( val test: Test ) extends Serializable - -class AfterEach( - val test: Test -) extends Serializable diff --git a/munit/shared/src/main/scala/munit/Fixture.scala b/munit/shared/src/main/scala/munit/Fixture.scala index 5f296ce5..35a51b24 100644 --- a/munit/shared/src/main/scala/munit/Fixture.scala +++ b/munit/shared/src/main/scala/munit/Fixture.scala @@ -13,8 +13,7 @@ abstract class Fixture[T](val fixtureName: String) { def beforeAll(): Any = () /** - * Runs before each individual test case. - * An error in this method aborts the test case. + * Runs before each individual test case. An error in this method aborts the test case. */ def beforeEach(context: BeforeEach): Any = () diff --git a/munit/shared/src/main/scala/munit/FutureFixture.scala b/munit/shared/src/main/scala/munit/FutureFixture.scala index dccac4c1..fa47c61c 100644 --- a/munit/shared/src/main/scala/munit/FutureFixture.scala +++ b/munit/shared/src/main/scala/munit/FutureFixture.scala @@ -5,7 +5,7 @@ import scala.concurrent.Future /** * Extend this class if you want to create a Fixture where all methods have `Future[Any]` as the result type. */ -abstract class FutureFixture[T](name: String) extends Fixture[Future[T]](name) { +abstract class FutureFixture[T](name: String) extends Fixture[T](name) { override def beforeAll(): Future[Any] = Future.successful(()) override def beforeEach(context: BeforeEach): Future[Any] = Future.successful(()) diff --git a/munit/shared/src/main/scala/munit/Suite.scala b/munit/shared/src/main/scala/munit/Suite.scala index c3eae8a0..516b703e 100644 --- a/munit/shared/src/main/scala/munit/Suite.scala +++ b/munit/shared/src/main/scala/munit/Suite.scala @@ -12,7 +12,8 @@ import scala.concurrent.Future abstract class Suite extends PlatformSuite { /** The value produced by test bodies. */ - type TestValue = Future[Any] + final type TestValue = Future[Any] + final type Fixture[T] = munit.Fixture[T] final type Test = munit.Test final type BeforeEach = munit.BeforeEach final type AfterEach = munit.AfterEach @@ -29,8 +30,6 @@ abstract class Suite extends PlatformSuite { } def munitExecutionContext: ExecutionContext = parasiticExecutionContext - type Fixture[T] = munit.Fixture[T] - /** * Runs once before all test cases and before all suite-local fixtures are setup. * An error in this method aborts the test suite. diff --git a/munit/shared/src/main/scala/munit/Test.scala b/munit/shared/src/main/scala/munit/Test.scala index d3e25492..74d9ff8c 100644 --- a/munit/shared/src/main/scala/munit/Test.scala +++ b/munit/shared/src/main/scala/munit/Test.scala @@ -11,7 +11,7 @@ import scala.concurrent.Future * @param tags the annotated tags for this test case. * @param location the file and line number where this test was defined. */ -sealed class Test( +final class Test( val name: String, val body: () => Future[Any], val tags: Set[Tag], diff --git a/tests/jvm/src/test/scala/munit/TimeoutSuite.scala b/tests/jvm/src/test/scala/munit/TimeoutSuite.scala index 619e9b59..f5a13510 100644 --- a/tests/jvm/src/test/scala/munit/TimeoutSuite.scala +++ b/tests/jvm/src/test/scala/munit/TimeoutSuite.scala @@ -7,6 +7,11 @@ import scala.concurrent.duration.FiniteDuration class TimeoutSuite extends munit.FunSuite { override val munitTimeout: FiniteDuration = Duration(100, "ms") + test("slow".fail) { + Future { + Thread.sleep(1000) + } + } test("infinite-loop".fail) { Future { while (true) { From 8a123cd8e9cb806de69cfc1f9cd2a39ac4a2c34f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93lafur=20P=C3=A1ll=20Geirsson?= Date: Mon, 11 Oct 2021 12:33:59 +0200 Subject: [PATCH 05/10] Optimization: don't run timeout logic for non-async tests --- .../scala/munit/internal/PlatformCompat.scala | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/munit/jvm/src/main/scala/munit/internal/PlatformCompat.scala b/munit/jvm/src/main/scala/munit/internal/PlatformCompat.scala index 38684e79..4eb2918e 100644 --- a/munit/jvm/src/main/scala/munit/internal/PlatformCompat.scala +++ b/munit/jvm/src/main/scala/munit/internal/PlatformCompat.scala @@ -33,21 +33,26 @@ object PlatformCompat { duration: Duration, ec: ExecutionContext ): Future[T] = { - val onComplete = Promise[T]() - var onCancel: () => Unit = () => () - future.onComplete { result => - onComplete.tryComplete(result) - }(ec) - val timeout = sh.schedule[Unit]( - () => - onComplete.tryFailure( - new TimeoutException(s"test timed out after $duration") - ), - duration.toMillis, - TimeUnit.MILLISECONDS - ) - onCancel = () => timeout.cancel(false) - onComplete.future + if (future.value.isDefined) { + // Avoid heavy timeout overhead for non-async tests. + future + } else { + val onComplete = Promise[T]() + var onCancel: () => Unit = () => () + future.onComplete { result => + onComplete.tryComplete(result) + }(ec) + val timeout = sh.schedule[Unit]( + () => + onComplete.tryFailure( + new TimeoutException(s"test timed out after $duration") + ), + duration.toMillis, + TimeUnit.MILLISECONDS + ) + onCancel = () => timeout.cancel(false) + onComplete.future + } } def isIgnoreSuite(cls: Class[_]): Boolean = From 2598fa6c6a9c2e3bb1ec4268356c3a10155c7720 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93lafur=20P=C3=A1ll=20Geirsson?= Date: Mon, 11 Oct 2021 12:49:47 +0200 Subject: [PATCH 06/10] Remove infinite loop test for TimeoutSuite This test case was causing CI failures on Linux that I wasn't able to reproduce on my local MacBook. --- tests/jvm/src/test/scala/munit/TimeoutSuite.scala | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/tests/jvm/src/test/scala/munit/TimeoutSuite.scala b/tests/jvm/src/test/scala/munit/TimeoutSuite.scala index f5a13510..2186aa2f 100644 --- a/tests/jvm/src/test/scala/munit/TimeoutSuite.scala +++ b/tests/jvm/src/test/scala/munit/TimeoutSuite.scala @@ -12,20 +12,6 @@ class TimeoutSuite extends munit.FunSuite { Thread.sleep(1000) } } - test("infinite-loop".fail) { - Future { - while (true) { - def fib(n: Int): Int = { - if (n < 1) 0 - else if (n == 1) n - else fib(n - 1) + fib(n - 2) - } - // Some computationally intensive calculation - 1.to(1000).foreach(i => fib(i)) - println("Loop") - } - } - } test("fast") { Future { Thread.sleep(1) From 68f9e6c6792551d48bb0a943f6a0dca8dc830551 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93lafur=20P=C3=A1ll=20Geirsson?= Date: Mon, 11 Oct 2021 12:58:26 +0200 Subject: [PATCH 07/10] Add infinite loop test back with a new execution context Previously, the test suite was using a parasitic execution context. --- .../src/test/scala/munit/TimeoutSuite.scala | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/tests/jvm/src/test/scala/munit/TimeoutSuite.scala b/tests/jvm/src/test/scala/munit/TimeoutSuite.scala index 2186aa2f..e8f6b480 100644 --- a/tests/jvm/src/test/scala/munit/TimeoutSuite.scala +++ b/tests/jvm/src/test/scala/munit/TimeoutSuite.scala @@ -4,15 +4,41 @@ import scala.concurrent.duration.Duration import scala.concurrent.Future import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.duration.FiniteDuration +import scala.concurrent.ExecutionContext class TimeoutSuite extends munit.FunSuite { override val munitTimeout: FiniteDuration = Duration(100, "ms") + override def munitExecutionContext: ExecutionContext = global + test("fast-1") { + Future { + Thread.sleep(1) + } + } test("slow".fail) { Future { Thread.sleep(1000) } } - test("fast") { + test("fast-2") { + Future { + Thread.sleep(1) + } + } + test("infinite-loop".fail) { + Future { + while (true) { + def fib(n: Int): Int = { + if (n < 1) 0 + else if (n == 1) n + else fib(n - 1) + fib(n - 2) + } + // Some computationally intensive calculation + 1.to(1000).foreach(i => fib(i)) + println("Loop") + } + } + } + test("fast-3") { Future { Thread.sleep(1) } From fd0ec0939c6c5f1e79fe3a11699a7830e72c3ce2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93lafur=20P=C3=A1ll=20Geirsson?= Date: Sat, 16 Oct 2021 09:53:30 +0200 Subject: [PATCH 08/10] Introduce `AnyFixture` to preserve `Unit` result type on `Fixture` Previously, the `Fixture` lifecycle methods returned `Any`, which meant that auto-completions would insert `Any` in the result type when auto-generating implementation of a fixture. Now, the `Fixture[T]` class has `Unit` as result types for consistency with older versions of MUnit. Instead, we introduce `AnyFixture[T]` which only needs to be used when comibing a list of `Fixture[T]` and `FutureFixture[T]` (along with 3rdparty fixture classes). --- .../src/main/scala/munit/AnyFixture.scala | 37 +++++++++++++++++++ .../shared/src/main/scala/munit/Fixture.scala | 37 +++++++++---------- .../src/main/scala/munit/FutureFixture.scala | 26 ++++++++++--- .../src/main/scala/munit/MUnitRunner.scala | 21 ++++++----- munit/shared/src/main/scala/munit/Suite.scala | 4 +- .../src/main/scala/munit/UnitFixture.scala | 11 ------ .../test/scala/munit/AsyncFixtureSuite.scala | 11 +++--- 7 files changed, 93 insertions(+), 54 deletions(-) create mode 100644 munit/shared/src/main/scala/munit/AnyFixture.scala delete mode 100644 munit/shared/src/main/scala/munit/UnitFixture.scala diff --git a/munit/shared/src/main/scala/munit/AnyFixture.scala b/munit/shared/src/main/scala/munit/AnyFixture.scala new file mode 100644 index 00000000..d44cc124 --- /dev/null +++ b/munit/shared/src/main/scala/munit/AnyFixture.scala @@ -0,0 +1,37 @@ +package munit + +/** + * AnyFixture allows you to acquire resources during setup and clean up resources after the tests finish running. + * + * Fixtures can be local to a single test case by overriding `beforeEach` and + * `afterEach`, or they can be re-used for an entire test suite by extending + * `beforeAll` and `afterAll`. + * + * It's preferable to use a sub-class like `munit.Fixture` or + * `munit.FutureFixture` instead of this class. Extend this class if you're + * writing an integration a third-party type like Cats `Resource`. + * + * @see https://scalameta.org/munit/docs/fixtures.html + * @param fixtureName The name of this fixture, used for displaying an error message if + * `beforeAll()` or `afterAll()` fail. + */ +abstract class AnyFixture[T](val fixtureName: String) { + + /** The value produced by this suite-local fixture that can be reused for all test cases. */ + def apply(): T + + /** Runs once before the test suite starts */ + def beforeAll(): Any = () + + /** + * Runs before each individual test case. An error in this method aborts the test case. + */ + def beforeEach(context: BeforeEach): Any = () + + /** Runs after each individual test case. */ + def afterEach(context: AfterEach): Any = () + + /** Runs once after the test suite has finished, regardless if the tests failed or not. */ + def afterAll(): Any = () + +} diff --git a/munit/shared/src/main/scala/munit/Fixture.scala b/munit/shared/src/main/scala/munit/Fixture.scala index 35a51b24..ec174704 100644 --- a/munit/shared/src/main/scala/munit/Fixture.scala +++ b/munit/shared/src/main/scala/munit/Fixture.scala @@ -1,26 +1,23 @@ package munit /** - * @param name The name of this fixture, used for displaying an error message if + * Fixture allows you to acquire resources during setup and clean up resources after the tests finish running. + * + * Fixtures can be local to a single test case by overriding `beforeEach` and + * `afterEach`, or they can be re-used for an entire test suite by extending + * `beforeAll` and `afterAll`. + * + * There is no functional difference between extending `Fixture[T]` or + * `AnyFixture[T]`. The only difference is that an IDE will auto-complete `Unit` + * in the result type instead of `Any`. + * + * @see https://scalameta.org/munit/docs/fixtures.html + * @param fixtureName The name of this fixture, used for displaying an error message if * `beforeAll()` or `afterAll()` fail. */ -abstract class Fixture[T](val fixtureName: String) { - - /** The value produced by this suite-local fixture that can be reused for all test cases. */ - def apply(): T - - /** Runs once before the test suite starts */ - def beforeAll(): Any = () - - /** - * Runs before each individual test case. An error in this method aborts the test case. - */ - def beforeEach(context: BeforeEach): Any = () - - /** Runs after each individual test case. */ - def afterEach(context: AfterEach): Any = () - - /** Runs once after the test suite has finished, regardless if the tests failed or not. */ - def afterAll(): Any = () - +abstract class Fixture[T](name: String) extends AnyFixture[T](name) { + override def beforeAll(): Unit = () + override def beforeEach(context: BeforeEach): Unit = () + override def afterEach(context: AfterEach): Unit = () + override def afterAll(): Unit = () } diff --git a/munit/shared/src/main/scala/munit/FutureFixture.scala b/munit/shared/src/main/scala/munit/FutureFixture.scala index fa47c61c..c38a6181 100644 --- a/munit/shared/src/main/scala/munit/FutureFixture.scala +++ b/munit/shared/src/main/scala/munit/FutureFixture.scala @@ -3,13 +3,27 @@ package munit import scala.concurrent.Future /** - * Extend this class if you want to create a Fixture where all methods have `Future[Any]` as the result type. + * FutureFixture allows you to acquire resources during setup and clean up resources after the tests finish running. + * + * Fixtures can be local to a single test case by overriding `beforeEach` and + * `afterEach`, or they can be re-used for an entire test suite by extending + * `beforeAll` and `afterAll`. + * + * There is no functional difference between extending `FutureFixture[T]` or + * `AnyFixture[T]`. The only difference is that an IDE will auto-complete + * `Future[Unit]` in the result type instead of `Any`. + * + * @see https://scalameta.org/munit/docs/fixtures.html + * @param fixtureName The name of this fixture, used for displaying an error message if + * `beforeAll()` or `afterAll()` fail. */ -abstract class FutureFixture[T](name: String) extends Fixture[T](name) { - override def beforeAll(): Future[Any] = Future.successful(()) - override def beforeEach(context: BeforeEach): Future[Any] = +abstract class FutureFixture[T](name: String) extends AnyFixture[T](name) { + override def beforeAll(): Future[Unit] = Future.successful(()) - override def afterEach(context: AfterEach): Future[Any] = + override def beforeEach(context: BeforeEach): Future[Unit] = + Future.successful(()) + override def afterEach(context: AfterEach): Future[Unit] = + Future.successful(()) + override def afterAll(): Future[Unit] = Future.successful(()) - override def afterAll(): Future[Any] = Future.successful(()) } diff --git a/munit/shared/src/main/scala/munit/MUnitRunner.scala b/munit/shared/src/main/scala/munit/MUnitRunner.scala index 9992f530..4cbe4116 100644 --- a/munit/shared/src/main/scala/munit/MUnitRunner.scala +++ b/munit/shared/src/main/scala/munit/MUnitRunner.scala @@ -51,15 +51,16 @@ class MUnitRunner(val cls: Class[_ <: Suite], newInstance: () => Suite) mutable.ArrayBuffer[Test](suite.munitTests(): _*) private val suiteFixture = new Fixture[Unit](cls.getName()) { def apply(): Unit = () - override def beforeAll(): Any = + override def beforeAll(): Unit = suite.beforeAll() - override def beforeEach(context: BeforeEach): Any = + override def beforeEach(context: BeforeEach): Unit = suite.beforeEach(context) - override def afterEach(context: AfterEach): Any = suite.afterEach(context) - override def afterAll(): Any = + override def afterEach(context: AfterEach): Unit = + suite.afterEach(context) + override def afterAll(): Unit = suite.afterAll() } - private lazy val munitFixtures: List[Fixture[_]] = + private lazy val munitFixtures: List[AnyFixture[_]] = suiteFixture :: suite.munitFixtures.toList override def filter(filter: Filter): Unit = { @@ -193,12 +194,12 @@ class MUnitRunner(val cls: Class[_ <: Suite], newInstance: () => Suite) private[munit] class BeforeAllResult( val isSuccess: Boolean, - val loadedFixtures: List[Fixture[_]], + val loadedFixtures: List[AnyFixture[_]], val errors: List[Throwable] ) private def runBeforeAll(notifier: RunNotifier): Future[BeforeAllResult] = { - val result: Future[List[Try[(Fixture[_], Boolean)]]] = + val result: Future[List[Try[(AnyFixture[_], Boolean)]]] = sequenceFutures( munitFixtures.iterator.map(f => runHiddenTest( @@ -235,14 +236,14 @@ class MUnitRunner(val cls: Class[_ <: Suite], newInstance: () => Suite) private[munit] class BeforeEachResult( val error: Option[Throwable], - val loadedFixtures: List[Fixture[_]] + val loadedFixtures: List[AnyFixture[_]] ) private def runBeforeEach( test: Test ): Future[BeforeAllResult] = { val context = new BeforeEach(test) - val fixtures = mutable.ListBuffer.empty[Fixture[_]] + val fixtures = mutable.ListBuffer.empty[AnyFixture[_]] sequenceFutures( munitFixtures.iterator.map(f => valueTransform(() => f.beforeEach(context)).map(_ => f) @@ -257,7 +258,7 @@ class MUnitRunner(val cls: Class[_ <: Suite], newInstance: () => Suite) private def runAfterEach( test: Test, - fixtures: List[Fixture[_]] + fixtures: List[AnyFixture[_]] ): Future[Unit] = { val context = new AfterEach(test) sequenceFutures( diff --git a/munit/shared/src/main/scala/munit/Suite.scala b/munit/shared/src/main/scala/munit/Suite.scala index 516b703e..ab856aa3 100644 --- a/munit/shared/src/main/scala/munit/Suite.scala +++ b/munit/shared/src/main/scala/munit/Suite.scala @@ -21,8 +21,8 @@ abstract class Suite extends PlatformSuite { /** The base class for all test suites */ def munitTests(): Seq[Test] - /** Functinonal fixtures that can be reused for individual test cases or entire suites. */ - def munitFixtures: Seq[Fixture[_]] = Nil + /** Fixtures that can be reused for individual test cases or entire suites. */ + def munitFixtures: Seq[AnyFixture[_]] = Nil private val parasiticExecutionContext = new ExecutionContext { def execute(runnable: Runnable): Unit = runnable.run() diff --git a/munit/shared/src/main/scala/munit/UnitFixture.scala b/munit/shared/src/main/scala/munit/UnitFixture.scala deleted file mode 100644 index 46e4472b..00000000 --- a/munit/shared/src/main/scala/munit/UnitFixture.scala +++ /dev/null @@ -1,11 +0,0 @@ -package munit - -/** - * Extend this class if you want to create a Fixture where all methods have `Unit` as the result type. - */ -abstract class UnitFixture[T](name: String) extends Fixture[T](name) { - override def beforeAll(): Unit = () - override def beforeEach(context: BeforeEach): Unit = () - override def afterEach(context: AfterEach): Unit = () - override def afterAll(): Unit = () -} diff --git a/tests/jvm/src/test/scala/munit/AsyncFixtureSuite.scala b/tests/jvm/src/test/scala/munit/AsyncFixtureSuite.scala index f414b0e1..44cb29a3 100644 --- a/tests/jvm/src/test/scala/munit/AsyncFixtureSuite.scala +++ b/tests/jvm/src/test/scala/munit/AsyncFixtureSuite.scala @@ -24,7 +24,7 @@ class AsyncFixtureSuite extends BaseSuite { private var promise = Promise[String]() private val timeout = 20 def apply(): String = promise.future.value.get.get - override def beforeAll(): Any = { + override def beforeAll(): Unit = { val setBeforeAllBit = Promise[Unit]() sh.schedule[Unit]( () => { @@ -36,7 +36,7 @@ class AsyncFixtureSuite extends BaseSuite { ) PromiseWrapper("beforeAll", setBeforeAllBit) } - override def beforeEach(context: BeforeEach): Any = { + override def beforeEach(context: BeforeEach): Unit = { assertEquals( promise.future.value, None, @@ -53,7 +53,7 @@ class AsyncFixtureSuite extends BaseSuite { ) PromiseWrapper("beforeEach", promise) } - override def afterEach(context: AfterEach): Any = { + override def afterEach(context: AfterEach): Unit = { val resetPromise = Promise[Unit]() sh.schedule[Unit]( () => { @@ -65,7 +65,7 @@ class AsyncFixtureSuite extends BaseSuite { ) PromiseWrapper("afterEach", resetPromise) } - override def afterAll(): Any = { + override def afterAll(): Unit = { val shutdownPromise = Promise[Unit]() ExecutionContext.global.execute(() => { Thread.sleep(timeout) @@ -79,7 +79,7 @@ class AsyncFixtureSuite extends BaseSuite { val message = new ScheduledMessage() val latest: Fixture[Unit] = new Fixture[Unit]("latest") { def apply(): Unit = () - override def afterAll(): Any = { + override def afterAll(): Unit = { assert( message.sh.isShutdown(), "message.afterAll did not complete yet. " + @@ -87,6 +87,7 @@ class AsyncFixtureSuite extends BaseSuite { ) } } + override def munitFixtures: Seq[Fixture[_]] = List(message, latest) 1.to(3).foreach { i => From 1b3854e680baacd199876f12a340efac30713b31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93lafur=20P=C3=A1ll=20Geirsson?= Date: Sat, 16 Oct 2021 10:12:06 +0200 Subject: [PATCH 09/10] Add more docs for the new fixtures --- docs/fixtures.md | 130 ++++++++++++++++++++++++++++------------------- 1 file changed, 79 insertions(+), 51 deletions(-) diff --git a/docs/fixtures.md b/docs/fixtures.md index 25d97296..2da405fe 100644 --- a/docs/fixtures.md +++ b/docs/fixtures.md @@ -120,57 +120,8 @@ class MySuite extends munit.FunSuite { } ``` -## Asynchronous fixtures - -Return a `Future`-like value from the methods `beforeAll`, `beforeEach`, -`afterEach` and `afterAll` to make an asynchronous fixture. By default, only -`Future[_]` values are recognized. Override `munitValueTransforms` to add -support for other `Future`-like types, see -[declare async tests](tests.md#declare-async-test) for more details. - -```scala mdoc:reset -import java.nio.file._ -import java.sql.Connection -import java.sql.DriverManager -import munit._ -import scala.concurrent.Future -import scala.concurrent.ExecutionContext.Implicits.global - -class AsyncFilesSuite extends FunSuite { - - // Test-local async fixture - val file = new Fixture[Path]("files") { - var file: Path = null - def apply() = file - override def beforeEach(context: BeforeEach): Future[Unit] = Future { - file = Files.createTempFile("files", context.test.name) - } - override def afterEach(context: AfterEach): Future[Unit] = Future { - // Always gets called, even if test failed. - Files.deleteIfExists(file) - } - } - - // Suite-local async fixture - val db = new Fixture[Connection]("database") { - private var connection: Connection = null - def apply() = connection - override def beforeAll(): Future[Unit] = Future { - connection = DriverManager.getConnection("jdbc:h2:mem:", "sa", null) - } - override def afterAll(): Future[Unit] = Future { - connection.close() - } - } - - override def munitFixtures = List(file, db) - - test("exists") { - // `file` is the temporary file that was created for this test case. - assert(Files.exists(file())) - } -} -``` +Next, extend `munitValueTransforms` to convert `Resource[T]` into `Future[T]`, +see [declare async tests](tests.md#declare-async-test) for more details. ## Ad-hoc test-local fixtures @@ -231,6 +182,83 @@ class MySuite extends munit.FunSuite { } ``` +## Asynchronous fixtures with `FutureFixture` + +Extend `FutureFixture[T]` to return `Future[T]` values from the lifecycle +methods `beforeAll`, `beforeEach`, `afterEach` and `afterAll`. + +```scala mdoc:reset +import java.nio.file._ +import java.sql.Connection +import java.sql.DriverManager +import munit.FutureFixture +import munit.FunSuite +import scala.concurrent.Future +import scala.concurrent.ExecutionContext.Implicits.global + +class AsyncFilesSuite extends FunSuite { + + // Test-local async fixture + val file = new FutureFixture[Path]("files") { + var file: Path = null + def apply() = file + override def beforeEach(context: BeforeEach): Future[Unit] = Future { + file = Files.createTempFile("files", context.test.name) + } + override def afterEach(context: AfterEach): Future[Unit] = Future { + // Always gets called, even if test failed. + Files.deleteIfExists(file) + } + } + + // Suite-local async fixture + val db = new FutureFixture[Connection]("database") { + private var connection: Connection = null + def apply() = connection + override def beforeAll(): Future[Unit] = Future { + connection = DriverManager.getConnection("jdbc:h2:mem:", "sa", null) + } + override def afterAll(): Future[Unit] = Future { + connection.close() + } + } + + override def munitFixtures = List(file, db) + + test("exists") { + // `file` is the temporary file that was created for this test case. + assert(Files.exists(file())) + } +} +``` + +## Asynchronous fixtures with custom effect type + +First, create a new `EffectFixture[T]` class that extends `munit.AnyFixture[T]` +and overrides all lifecycle methods to return values of type `Effect[Unit]`. For +example: + +```scala mdoc:reset +import munit.AfterEach +import munit.BeforeEach + +// Hypothetical effect type called "Resource" +sealed abstract class Resource[+T] +object Resource { + def unit: Resource[Unit] = ??? +} + +abstract class ResourceFixture[T](name: String) extends munit.AnyFixture[T](name) { + // The main purpose of "ResourceFixture" is to help IDEs auto-complete + // the result type "Resource[Unit]" instead of "Any" when implementing the + // "ResourceFixture" class. + override def beforeAll(): Resource[Unit] = Resource.unit + override def beforeEach(context: BeforeEach): Resource[Unit] = Resource.unit + override def afterEach(context: AfterEach): Resource[Unit] = Resource.unit + override def afterAll(): Resource[Unit] = Resource.unit +} +``` + ## Avoid stateful operations in the class constructor Test classes may sometimes get initialized even if no tests run so it's best to From 9e081b3715b9bda52e9b6a04f5c6beaf542166d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93lafur=20P=C3=A1ll=20Geirsson?= Date: Sat, 16 Oct 2021 10:12:09 +0200 Subject: [PATCH 10/10] Fix failing async fixture tests --- docs/fixtures.md | 21 ++++--------------- .../test/scala/munit/AsyncFixtureSuite.scala | 12 +++++------ 2 files changed, 10 insertions(+), 23 deletions(-) diff --git a/docs/fixtures.md b/docs/fixtures.md index 2da405fe..e5503af6 100644 --- a/docs/fixtures.md +++ b/docs/fixtures.md @@ -120,9 +120,6 @@ class MySuite extends munit.FunSuite { } ``` -Next, extend `munitValueTransforms` to convert `Resource[T]` into `Future[T]`, -see [declare async tests](tests.md#declare-async-test) for more details. - ## Ad-hoc test-local fixtures Override `beforeEach()` and `afterEach()` to add custom logic that should run @@ -198,7 +195,6 @@ import scala.concurrent.ExecutionContext.Implicits.global class AsyncFilesSuite extends FunSuite { - // Test-local async fixture val file = new FutureFixture[Path]("files") { var file: Path = null def apply() = file @@ -211,19 +207,7 @@ class AsyncFilesSuite extends FunSuite { } } - // Suite-local async fixture - val db = new FutureFixture[Connection]("database") { - private var connection: Connection = null - def apply() = connection - override def beforeAll(): Future[Unit] = Future { - connection = DriverManager.getConnection("jdbc:h2:mem:", "sa", null) - } - override def afterAll(): Future[Unit] = Future { - connection.close() - } - } - - override def munitFixtures = List(file, db) + override def munitFixtures = List(file) test("exists") { // `file` is the temporary file that was created for this test case. @@ -259,6 +243,9 @@ abstract class ResourceFixture[T](name: String) extends munit.AnyFixture[T](name } ``` +Next, extend `munitValueTransforms` to convert `Resource[T]` into `Future[T]`, +see [declare async tests](tests.md#declare-async-test) for more details. + ## Avoid stateful operations in the class constructor Test classes may sometimes get initialized even if no tests run so it's best to diff --git a/tests/jvm/src/test/scala/munit/AsyncFixtureSuite.scala b/tests/jvm/src/test/scala/munit/AsyncFixtureSuite.scala index 44cb29a3..d1784dd1 100644 --- a/tests/jvm/src/test/scala/munit/AsyncFixtureSuite.scala +++ b/tests/jvm/src/test/scala/munit/AsyncFixtureSuite.scala @@ -17,14 +17,14 @@ class AsyncFixtureSuite extends BaseSuite { } ) ) - class ScheduledMessage() extends Fixture[String]("AsyncFixture") { + class ScheduledMessage() extends AnyFixture[String]("AsyncFixture") { val sh: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor() private var didBeforeAllEvaluateAsync = false private var promise = Promise[String]() private val timeout = 20 def apply(): String = promise.future.value.get.get - override def beforeAll(): Unit = { + override def beforeAll(): PromiseWrapper = { val setBeforeAllBit = Promise[Unit]() sh.schedule[Unit]( () => { @@ -36,7 +36,7 @@ class AsyncFixtureSuite extends BaseSuite { ) PromiseWrapper("beforeAll", setBeforeAllBit) } - override def beforeEach(context: BeforeEach): Unit = { + override def beforeEach(context: BeforeEach): PromiseWrapper = { assertEquals( promise.future.value, None, @@ -53,7 +53,7 @@ class AsyncFixtureSuite extends BaseSuite { ) PromiseWrapper("beforeEach", promise) } - override def afterEach(context: AfterEach): Unit = { + override def afterEach(context: AfterEach): PromiseWrapper = { val resetPromise = Promise[Unit]() sh.schedule[Unit]( () => { @@ -65,7 +65,7 @@ class AsyncFixtureSuite extends BaseSuite { ) PromiseWrapper("afterEach", resetPromise) } - override def afterAll(): Unit = { + override def afterAll(): PromiseWrapper = { val shutdownPromise = Promise[Unit]() ExecutionContext.global.execute(() => { Thread.sleep(timeout) @@ -88,7 +88,7 @@ class AsyncFixtureSuite extends BaseSuite { } } - override def munitFixtures: Seq[Fixture[_]] = List(message, latest) + override def munitFixtures: Seq[AnyFixture[_]] = List(message, latest) 1.to(3).foreach { i => test(s"test-$i") {