Sharing resources across suites

A word of warning

This feature works only on JVM, and has been tested in SBT, Mill and Bloop.

When using weaver manually, outside of the build tool, with a standalone runner (we do provide one), please disregard this mechanism and use classic dependency injection and/or your own wits to share resources across suites.

Declaring global resources

In order to declare global resources, which suites will be able to share, weaver provides a GlobalResource interface that users can implement. This interface sports a method that takes a GlobalWrite instance, which contains semantics to store items, indexing them by types.

NB : the implementations have to be static objects.

import weaver._

import cats.effect.IO
import cats.effect.Resource

// note how this is a static object
object SharedResources extends GlobalResource {
  def sharedResources(global: GlobalWrite): Resource[IO, Unit] =
    for {
      foo <- Resource.pure[IO, String]("hello world!")
      _   <- global.putR(foo)
    } yield ()
}

Accessing global resources

On the suite side, accessing the global resources happen via declaring a constructor on your suite that takes a single parameter of type GlobalRead.

This item can be used to access the resources that were previously initialised and stored in the GlobalResource.

class SharingSuite(global: GlobalRead) extends IOSuite {
  type Res = String
  def sharedResource: Resource[IO, String] =
    global.getOrFailR[String]()

  test("a stranger, from the outside ! ooooh") { sharedString =>
    IO(expect(sharedString == "hello world!"))
  }
}

class OtherSharingSuite(global: GlobalRead)
    extends IOSuite {
  type Res = Option[Int]

  // We didn't store an `Int` value in our `GlobalResourcesInit` impl
  def sharedResource: Resource[IO, Option[Int]] =
    global.getR[Int]()

  test("oops, forgot something here") { sharedInt =>
    IO(expect(sharedInt.isEmpty))
  }
}

Lifecycle

Weaver guarantees the following order :

This implies that all resources declared in GlobalResource will remain alive/active until all tests have run.

Regarding "testOnly" build tool commands

Some build tools provide a "testOnly" (or equivalent) command that lets you test a single suite. Because of how weaver taps into the same detection mechanism build tools use to communicate suites to the framework, you should either :

An example of how to do this:

// package yourproject.resource

import cats.effect.{ IO, Resource }
import weaver._

object MyResources extends GlobalResource {
  override def sharedResources(global: GlobalWrite): Resource[IO, Unit] =
    baseResources.flatMap(global.putR(_))

  def baseResources: Resource[IO, String] = Resource.pure[IO, String]("hello world!")

  // Provides a fallback to support running individual tests via testOnly
  def sharedResourceOrFallback(read: GlobalRead): Resource[IO, String] =
    read.getR[String]().flatMap {
      case Some(value) => Resource.eval(IO(value))
      case None        => baseResources
    }
}

// package yourproject.somepackage

class MySuite(global: GlobalRead) extends IOSuite {
  import MyResources._

  override type Res = String

  def sharedResource: Resource[IO, String] = sharedResourceOrFallback(global)

  test("a stranger, from the outside ! ooooh") { sharedString =>
    IO(expect(sharedString == "hello world!"))
  }
}

// package yourproject.somepackage

class MyOtherSuite(global: GlobalRead) extends IOSuite {
  import MyResources._

  override type Res = String

  def sharedResource: Resource[IO, String] = sharedResourceOrFallback(global)

  test("oops, forgot something here") { sharedString =>
    IO(expect(sharedString == "hello world!"))
  }
}

Run through SBT with:

sbt testOnly *My*Suite yourproject.resource.MyResources

Regarding global resource indexing

Runtime constraints

The shared items are indexed via weaver.ResourceTag, a custom typeclass that has a default instance for based on scala.reflect.ClassTag. This implies that the default instance only works for types that are not subject to type-erasure.

If the user wants to share resources which are subject to type-erasure (ie that have type parameters, such as org.http4s.client.Client), they have to provide an instance themselves, or alternatively use a monomorphic wrapper (which is not subject to type-erasure).

import weaver._

import cats.effect.IO
import cats.effect.Resource

// Class subject to type-erasure
case class Foo[A](value : A)

object FailingSharedResources extends GlobalResource {
  def sharedResources(global: GlobalWrite): Resource[IO, Unit] =
    global.putR(Foo("hello"))
}
// error:
// 
// Could not find an implicit ResourceTag instance for type repl.MdocSession.MdocApp.Foo[String]
// This is likely because repl.MdocSession.MdocApp.Foo is subject to type erasure. You can implement a ResourceTag manually or wrap the item you are trying to store/access, in some monomorphic case class that is not subject to type erasure
// 
// 
// Error occurred in an application involving default arguments.
//     global.putR(Foo("hello"))
//     ^^^^^^^^^^^^^^^^^^^^^^^^^

Labelling

On the two sides of (production and consumption) of the global resources, it is possible to label the resources with string values, to discriminate between several resources of the same type.

import cats.syntax.all._

object LabelSharedResources extends GlobalResource {
  def sharedResources(global: GlobalWrite): Resource[IO, Unit] =
    for {
      _ <- global.putR(1, "one".some)
      _ <- global.putR(2, "two".some)
    } yield ()
}

class LabelSharingSuite(global: GlobalRead)
    extends IOSuite {

  type Res = Int

  // We didn't store an `Int` value in our `GlobalResourcesInit` impl
  def sharedResource: Resource[IO, Int] = for {
    one <- global.getOrFailR[Int]("one".some)
    two <- global.getOrFailR[Int]("two".some)
  } yield one + two

  test("labels work") { sharedInt =>
    IO(expect(sharedInt == 3))
  }
}