Table of Contents

Custom Test Class Construction 2025 November 2

As of version 3.2.0, we are now supporting a way to override the default test class construction behavior.

Why Override?

By default, test class constructor arguments can be:

Any constructor argument which cannot be resolved will generate an error when running the test:

The following constructor parameters did not have matching fixture data

Some developers may want to use additional logic to resolve these missing constructor arguments. One common scenario might be to allow a dependency injection system to resolve those arguments; for example, to give tests access to services registered that the production code will be using.

Now they can provide an alternative construction system for test classes.

Implementing ITypeActivator

The new ITypeActivator interface contains a single method:

object CreateInstance(
    ConstructorInfo constructor,
    object?[]? arguments,
    Func<Type, IReadOnlyCollection<ParameterInfo>, string> missingArgumentMessageFormatter);

The activator is given the constructor that the test framework has selected, along with all the arguments that it could collect. Any argument value which could not be found will be represented in the array by Missing.Value.

The activator should resolve all the missing values and then constructor and return the object. If one or more missing values cannot be resolved, it is expected that the activator will throw an instance of TestPipelineException. A missingArgumentMessageFormatter function is provided to the activator, which can be called with (a) the Type that's being created, and (b) the list of ParameterInfo that are missing values (this allows the caller to express the context in which the creation failed; this is where the message above is provided by the test framework).

The default type activator cannot resolve any unknown parameters, so its implementation looks like this:

object ITypeActivator.CreateInstance(
    ConstructorInfo constructor,
    object?[]? arguments,
    Func<Type, IReadOnlyCollection<ParameterInfo>, string> missingArgumentMessageFormatter)
{
    if (constructor is null)
        throw new ArgumentNullException(nameof(constructor));
    if (missingArgumentMessageFormatter is null)
        throw new ArgumentNullException(nameof(missingArgumentMessageFormatter));

    var type =
        constructor.ReflectedType
            ?? constructor.DeclaringType
            ?? throw new ArgumentException("Untyped constructors are not permitted", nameof(constructor));

    if (arguments is not null)
    {
        var parameters = constructor.GetParameters();
        if (parameters.Length != arguments.Length)
            throw new TestPipelineException(
                string.Format(
                    CultureInfo.CurrentCulture,
                    "Cannot create type '{0}' due to parameter count mismatch (needed {1}, got {2})",
                    type.FullName ?? type.Name,
                    parameters.Length,
                    arguments.Length
                )
            );

        var missingArguments =
            arguments
                .Select((a, idx) => a is Missing ? parameters[idx] : null)
                .WhereNotNull()
                .CastOrToReadOnlyCollection();

        if (missingArguments.Count != 0)
            throw new TestPipelineException(missingArgumentMessageFormatter(type, missingArguments));
    }

    return constructor.Invoke(arguments);
}

Registering your type activator

It is assumed that things like dependency injection containers will be created by way of an early registration system like ITestPipelineStartup.

Once the container is fully configured and the type activator has been created, it is registered by calling:

Xunit.v3.TypeActivator.Current = MY_TYPE_ACTIVATOR_INSTANCE;

It is assumed that a type activator will be created and registered once during the pipeline startup and left in place for the duration of the test assembly lifetime.