Skip to content

Mocking Resources

  • 🌰 In a nutshell...

    • You can create fake resources for unit tests. Mocking Resources
    • Fake resources are created with ResourceUtils.CreateCustom(), using a hand-written fake or a mocking library. Creating Fakes
    • Fake resources are only for testing your own code; passing them in to TinyFFR itself throws an exception. Limitations

Mocking Resources#

static float GetDistanceFromOrigin(Camera camera) { // (1)!
    return camera.Position.DistanceFrom(Location.Origin);
}

var impl = Substitute.For<ICameraImplProvider>(); // (2)!
impl.GetPosition(Arg.Any<ResourceHandle<Camera>>()).Returns(new Location(3f, 0f, 4f));
var camera = ResourceUtils.CreateCustom<Camera, ICameraImplProvider>(1, impl); // (3)!

Assert.That(GetDistanceFromOrigin(camera), Is.EqualTo(5f).Within(0.0001f)); // (4)!
  1. Some code to be tested, which uses a Camera.

  2. Creates a fake ICameraImplProvider with the NSubstitute mocking library, and configures it to report the camera's position as (3, 0, 4).

  3. Creates a Camera that uses the fake implementation.

  4. The code under test can now be run against the fake camera, with no factory, window, or GPU required.

Every resource in TinyFFR (Texture, Mesh, Camera, Scene, etc) is a small struct holding two things:

  • A handle, which identifies the resource.
  • An implementation provider (ITextureImplProvider, IMeshImplProvider, ICameraImplProvider, etc), which every property and method on the resource forwards to, passing along the handle.

Normally the implementation provider is part of the factory, and does the real work of managing the resource. But because implementation providers are ordinary public interfaces, you can supply your own; this lets you unit test code that uses TinyFFR resources without creating a factory or any real resources.

Creating Fakes#

ResourceUtils.CreateCustom<TResource, TImpl>(handle, impl) creates a resource of type TResource that forwards everything to impl:

handle

Any value you like (it's implicitly convertible from an integer or pointer). It's passed to every method called on impl, so a single implementation can tell multiple fake resources apart by their handles.

impl

Your implementation of the resource's implementation provider interface (TImpl), e.g. ITextureImplProvider for a Texture. Must not be null.

Any mocking library that can mock interfaces will work, as shown at the top of this page using NSubstitute. Alternatively, you can write a fake implementation by hand:

Function Pointer Parameters

Some implementation providers have methods with function pointer (delegate*) parameters. Mocking libraries built on Castle DynamicProxy (including NSubstitute and Moq) can't create fakes of interfaces with such methods, and throw an exception when you try. Write a fake by hand for these interfaces instead, as below.

// NOTE: This example may not be up-to-date with the latest API and serves as just an example
sealed unsafe class FakeTextureImplProvider : ITextureImplProvider {
    public XYPair<int> Dimensions { get; set; } = new(64, 64);
    public bool WasDisposed { get; private set; }

    public XYPair<int> GetDimensions(ResourceHandle<Texture> handle) => Dimensions; // (1)!
    public TexelType GetTexelType(ResourceHandle<Texture> handle) => TexelType.Rgb24;
    public bool GetAllowsDynamicWrites(ResourceHandle<Texture> handle) => false;
    public bool GetContainsMipMaps(ResourceHandle<Texture> handle) => true;
    public TextureRenderingConfig GetRenderingConfig(ResourceHandle<Texture> handle) => default;
    public void OverwriteTexels<TTexel>(ResourceHandle<Texture> handle, ReadOnlySpan<TTexel> newTexels, XYPair<int> dimensions, XYPair<int> offset)
        where TTexel : unmanaged, IConversionSupplyingTexel<TTexel, TexelRgb24>, IConversionSupplyingTexel<TTexel, TexelRgba32> { }
    public void OverwriteTexelsParallelized<TTexel, TContext>(ResourceHandle<Texture> handle, delegate* managed<Span<TTexel>, XYPair<int>, XYPair<int>, TContext, void> blockCallback, TContext context, XYPair<int> dimensions, XYPair<int> offset)
        where TTexel : unmanaged, IConversionSupplyingTexel<TTexel, TexelRgb24>, IConversionSupplyingTexel<TTexel, TexelRgba32> => throw new NotSupportedException();

    public bool IsDisposed(ResourceHandle<Texture> handle) => WasDisposed; // (2)!
    public void Dispose(ResourceHandle<Texture> handle) => WasDisposed = true;

    public string GetNameAsNewStringObject(ResourceHandle<Texture> handle) => "Fake Texture"; // (3)!
    public int GetNameLength(ResourceHandle<Texture> handle) => "Fake Texture".Length;
    public void CopyName(ResourceHandle<Texture> handle, Span<char> destinationBuffer) => "Fake Texture".CopyTo(destinationBuffer);
}

var impl = new FakeTextureImplProvider { Dimensions = new(1920, 1080) };
var texture = ResourceUtils.CreateCustom<Texture, ITextureImplProvider>(1, impl);
  1. Invoked whenever texture.Dimensions is read.

  2. IsDisposed() and Dispose() are part of every disposable resource's implementation provider. Here, the fake records whether the texture was disposed, so that a test can check that the code under test disposed it.

  3. The name methods are part of every resource's implementation provider, and are used by e.g. texture.GetNameAsNewStringObject() and texture.ToString().

Extracting Handles & Implementations#

ResourceUtils also offers the reverse operations, which work on any resource (fake or real):

ResourceUtils.ExtractHandle(resource)

Returns the resource's handle. Throws an ObjectDisposedException if the resource has been disposed. Note that the handle is only an identifier; for real resources it may represent a pointer to memory that no longer exists after the resource is disposed.

ResourceUtils.ExtractImplementation<TResource, TImpl>(resource)

Returns the resource's implementation provider. For a fake resource, this is the impl it was created with.

These are useful in tests for checking which resource some code returned or used; for example, verifying that a method returned the specific fake texture it was given.

Two resources are equal (via == or Equals()) when they have both the same handle and the same implementation provider instance. So two fakes with the same handle but different implementations are not equal.

Don't use ResourceUtils to Work Around TinyFFR's Public API

You may be tempted to use the methods shown above to work around the public library API and invoke "hidden" implementation methods manually. For example, IsDisposed() is available as a method on each implementation provider interface, but no resource type exposes it.

In some cases we do not want to expose internal implementation details because we either don't want consumers of the library to rely on them (i.e. they're subject to change or may be deprecated).

In other cases it may be because their usage is non-obvious and better exposed in other ways. For example, resource types that use handles that are actually pointers in re-usable arena allocator tables can "resurrect", meaning a theoretical IsDisposed could "change" from true back to false. That is why there is no public exposure of the IsDisposed() method today.

If there's an API hole that you really need added, better to raise an issue on TinyFFR's Github repository.

Limitations#

Fake resources are not tracked by any factory. They don't appear in the resource directory, and have no dependencies tracked.

Fakes Can't Be Used By TinyFFR

Fake resources are only for testing your own code. Passing a fake resource in to the factory or anything created by it (e.g. creating a material from a fake texture, or adding a fake model instance to a real scene) throws an InvalidOperationException.

Comments