BrandGhost
Testing Plugin Architectures in C#: Strategies for Extensible Systems

Testing Plugin Architectures in C#: Strategies for Extensible Systems

Testing Plugin Architectures in C#: Strategies for Extensible Systems

If you've built a testing plugin architectures in C# system, you already know what makes it powerful: plugins are discovered at runtime, loaded dynamically, and executed against a contract your host defines. What you might not have figured out yet is how to test all of that reliably.

Standard unit testing advice breaks down quickly here. You can't just instantiate your subject under test and call its methods -- not when the system is designed to accept unknown implementations loaded from arbitrary DLL files. The open-ended nature of plugin systems is exactly what makes them hard to test. But it also means testing is even more important, because a broken plugin boundary is invisible until it crashes in production.

This article walks through three concrete layers of testing for plugin architectures: verifying plugin implementations, validating contract conformance, and integration testing the full host-plus-plugin loading pipeline.

The approach to testing plugin architecture in C# that works best is a layered one. Each layer targets a different failure mode and runs at a different speed. Getting the layers right means you can run the fastest tests on every save and reserve the slower tests for your CI pipeline -- without sacrificing coverage of the failures that matter most.

If you're still designing your plugin system, check out the foundations in Plugin Architecture in C# for Improved Software Design before diving in here.

Why Testing Plugin Architecture Is Different

Most unit testing follows a simple pattern: construct the object, inject fakes for dependencies, call a method, assert a result. Plugin architectures break that pattern in a few specific ways.

First, plugins are discovered at runtime. Your host loads DLLs from a directory, reflects over types, and activates whatever implements your contract interface. You can't pre-wire dependencies the same way you would in a typical DI setup.

Second, the system is deliberately open. Plugins written now will be joined by plugins someone else writes next month. Your test suite can't enumerate all future plugins -- but it can validate that any plugin meets the contract.

Third, failure modes are different. A plugin that throws during initialization shouldn't crash the host. A missing directory shouldn't halt startup. A plugin compiled against the wrong contract version shouldn't silently misbehave.

These three realities map to three layers of testing:

  • Contract tests -- does any given plugin meet the interface expectations?
  • Unit tests -- does each concrete plugin do what it's supposed to do?
  • Integration tests -- does the host discover, load, and execute plugins correctly end-to-end?

These differences are what make testing plugin architecture a worthwhile discipline rather than a box-checking exercise. When the layers are right, a broken unit test tells you a plugin's logic is wrong. A broken contract test tells you a plugin no longer meets the interface expectations. A broken integration test tells you the loading pipeline itself has a problem. That specificity makes debugging a plugin system tractable instead of mysterious.

Unit Testing Plugin Implementations

A concrete plugin class is still just a class. You don't need anything exotic to unit test it -- instantiate it, call its methods, assert the output.

Consider a plugin interface and a concrete implementation:

// Plugin contract (lives in your shared contracts assembly)
public interface IAnalyticsPlugin
{
    string Name { get; }
    Task<AnalyticsSummary> ComputeAsync(IReadOnlyList<DataPoint> data);
}

// Concrete plugin (lives in a separate plugin assembly)
public class AverageAnalyticsPlugin : IAnalyticsPlugin
{
    public string Name => "Average";

    public Task<AnalyticsSummary> ComputeAsync(IReadOnlyList<DataPoint> data)
    {
        var avg = data.Average(d => d.Value);
        return Task.FromResult(new AnalyticsSummary(avg));
    }
}

Testing this is straightforward with xUnit:

public class AverageAnalyticsPluginTests
{
    [Fact]
    public async Task ComputeAsync_WithValidData_ReturnsCorrectAverage()
    {
        // Arrange
        var plugin = new AverageAnalyticsPlugin();
        var data = new List<DataPoint>
        {
            new(10.0), new(20.0), new(30.0)
        };

        // Act
        var result = await plugin.ComputeAsync(data);

        // Assert
        Assert.Equal(20.0, result.Value, precision: 5);
    }

    [Fact]
    public void Name_ReturnsExpectedIdentifier()
    {
        var plugin = new AverageAnalyticsPlugin();
        Assert.Equal("Average", plugin.Name);
    }
}

If your plugin depends on IServiceProvider, resist the urge to mock it with a complex setup. Build a minimal in-memory DI container instead -- it keeps tests readable and closer to how the plugin will actually run:

[Fact]
public async Task ComputeAsync_WithServiceProvider_ResolvesCorrectly()
{
    // Build a real (minimal) DI container for the test
    var services = new ServiceCollection();
    services.AddSingleton<IDataNormalizer, PassThroughNormalizer>();
    var provider = services.BuildServiceProvider();

    var plugin = new NormalizedAnalyticsPlugin(provider);
    var data = new List<DataPoint> { new(100.0) };

    var result = await plugin.ComputeAsync(data);

    Assert.NotNull(result);
}

This approach avoids mocking framework overhead and forces your plugin constructor to accept only what it genuinely needs.

Testing the Plugin Contract Interface

Unit testing individual plugins is necessary, but not sufficient. As your plugin ecosystem grows, you need a way to guarantee that every plugin -- current and future -- honors the full contract.

The contract test pattern solves this. You create an abstract xUnit base class that defines tests every plugin must pass. Concrete test classes inherit the base and supply the specific plugin under test:

// Abstract base -- any plugin must pass these tests
public abstract class PluginContractTests<TPlugin>
    where TPlugin : IAnalyticsPlugin
{
    // Derived class supplies the plugin instance
    protected abstract TPlugin CreatePlugin();

    [Fact]
    public void Name_IsNotNullOrEmpty()
    {
        var plugin = CreatePlugin();
        Assert.False(string.IsNullOrWhiteSpace(plugin.Name));
    }

    [Fact]
    public async Task ComputeAsync_WithEmptyList_DoesNotThrow()
    {
        var plugin = CreatePlugin();
        // A well-behaved plugin handles empty input gracefully
        var result = await plugin.ComputeAsync(new List<DataPoint>());
        Assert.NotNull(result);
    }

    [Fact]
    public async Task ComputeAsync_WithSinglePoint_ReturnsResult()
    {
        var plugin = CreatePlugin();
        var data = new List<DataPoint> { new(42.0) };
        var result = await plugin.ComputeAsync(data);
        Assert.NotNull(result);
    }
}

// Concrete test class for AverageAnalyticsPlugin
public class AverageAnalyticsPluginContractTests
    : PluginContractTests<AverageAnalyticsPlugin>
{
    protected override AverageAnalyticsPlugin CreatePlugin()
        => new AverageAnalyticsPlugin();
}

xUnit runs all tests defined in the base class through each concrete subclass automatically. When you add a new plugin, you add one new test class that inherits the base, and every contract invariant is verified without duplicating test logic.

This pattern scales cleanly with large plugin catalogs. The Plugin Architecture Design Pattern -- A Beginner's Guide to Modularity article explains the contract design decisions that underpin this approach.

The contract test pattern is the most powerful tool in the testing plugin architecture toolkit for long-term quality. As your catalog grows, new plugins pass through contract tests automatically -- nobody needs to maintain a list of what to test. The base class handles it. At twenty plugins this saves hundreds of lines of duplicated test code while keeping contract coverage airtight.

Testing Plugin Discovery Logic

Plugin discovery is where a lot of subtle bugs live. Your host scans a directory, loads assemblies, reflects over types, and picks out anything implementing the contract. Testing this against a real file system is slow, brittle, and requires you to ship test DLLs alongside your tests.

A better approach: extract the file system operations behind an interface, then inject a test double.

// Abstraction over the file system concern
public interface IPluginDirectory
{
    IEnumerable<string> GetAssemblyPaths();
}

// Discovery logic depends on the interface, not the file system
public class PluginDiscovery
{
    private readonly IPluginDirectory _directory;

    public PluginDiscovery(IPluginDirectory directory)
    {
        _directory = directory;
    }

    public IReadOnlyList<Type> DiscoverPluginTypes()
    {
        var results = new List<Type>();

        foreach (var path in _directory.GetAssemblyPaths())
        {
            try
            {
                // Note: Assembly.LoadFrom loads into the default context, which differs from
                // ALC-based loading used in production. For unit tests of discovery logic this
                // is acceptable; for full pipeline tests use AssemblyLoadContext instead.
                var assembly = Assembly.LoadFrom(path);
                var pluginTypes = assembly.GetExportedTypes()
                    .Where(t => typeof(IAnalyticsPlugin).IsAssignableFrom(t)
                             && t is { IsAbstract: false, IsInterface: false });
                results.AddRange(pluginTypes);
            }
            catch (Exception)
            {
                // Skip bad DLLs -- don't crash the host
            }
        }

        return results;
    }
}

Your tests can now supply a fake directory without touching the file system:

// Fake directory for unit tests -- no real DLLs required
public class FakePluginDirectory : IPluginDirectory
{
    private readonly List<string> _paths;

    public FakePluginDirectory(params string[] paths)
        => _paths = new List<string>(paths);

    public IEnumerable<string> GetAssemblyPaths() => _paths;
}

public class PluginDiscoveryTests
{
    [Fact]
    public void DiscoverPluginTypes_WithEmptyDirectory_ReturnsEmpty()
    {
        var discovery = new PluginDiscovery(new FakePluginDirectory());
        var types = discovery.DiscoverPluginTypes();
        Assert.Empty(types);
    }

    [Fact]
    public void DiscoverPluginTypes_WithInvalidPath_DoesNotThrow()
    {
        // Non-existent path should be swallowed, not thrown
        var discovery = new PluginDiscovery(
            new FakePluginDirectory(@"C:
onexistentfake.dll"));

        var types = discovery.DiscoverPluginTypes();
        Assert.Empty(types);
    }
}

This approach keeps discovery tests fast and deterministic. The real FileSystemPluginDirectory implementation only needs one or two targeted integration tests against a known temp directory -- the bulk of the logic is covered cheaply via the fake.

Integration Testing: Host + Real Plugins

Sometimes you genuinely need to test the full pipeline: load an assembly, discover a type, activate it, and execute it. This is where integration tests earn their place.

The approach that works best in .NET is to compile a known-good test plugin into a separate project, copy it to the test output directory, and load it in your integration test using AssemblyLoadContext.

In your test project's .csproj, add a ProjectReference to the test plugin project with ReferenceOutputAssembly=false and a custom target to copy the DLL to the output folder. Then load it in a test:

public class PluginLoadingIntegrationTests
{
    [Fact]
    public async Task LoadPlugin_FromAssembly_ExecutesCorrectly()
    {
        var pluginPath = Path.Combine(
            AppContext.BaseDirectory, "TestPlugin.dll");

        Assert.True(File.Exists(pluginPath),
            "Test plugin DLL not found in output directory.");

        // Collectible context lets us unload after the test
        var context = new AssemblyLoadContext(
            name: "TestPluginContext",
            isCollectible: true);

        try
        {
            var assembly = context.LoadFromAssemblyPath(pluginPath);
            var pluginType = assembly.GetExportedTypes()
                .Single(t => typeof(IAnalyticsPlugin).IsAssignableFrom(t));

            var plugin = (IAnalyticsPlugin)Activator.CreateInstance(pluginType)!;

            var data = new List<DataPoint> { new(10.0), new(20.0) };
            var result = await plugin.ComputeAsync(data);

            Assert.Equal(15.0, result.Value, precision: 5);
        }
        finally
        {
            context.Unload();
        }
    }
}

Using a collectible AssemblyLoadContext lets you initiate unloading after the test, reducing cross-test pollution. Note that Unload() does not guarantee immediate collection -- use a WeakReference to verify the context was actually reclaimed if test isolation is critical. For more on integrating plugin loading into real ASP.NET Core applications, see Plugin Architecture in ASP.NET Core -- How To Master It.

Testing Plugin Failure Scenarios

A robust plugin host must handle misbehaving plugins without crashing. Testing failure scenarios explicitly is what separates a fragile system from a production-ready one.

The three failure modes worth testing explicitly are a plugin that throws during execution, a plugin that returns null, and a plugin that throws during initialization. For the first two:

// Deliberately broken plugins for failure testing
public class ThrowingPlugin : IAnalyticsPlugin
{
    public string Name => "Throwing";

    public Task<AnalyticsSummary> ComputeAsync(IReadOnlyList<DataPoint> data)
        => throw new InvalidOperationException("Simulated plugin failure.");
}

public class NullReturningPlugin : IAnalyticsPlugin
{
    public string Name => "NullReturning";

    public Task<AnalyticsSummary> ComputeAsync(IReadOnlyList<DataPoint> data)
        => Task.FromResult<AnalyticsSummary>(null!);
}

public class PluginHostFailureTests
{
    [Fact]
    public async Task Host_WhenPluginThrows_ContinuesWithOtherPlugins()
    {
        var host = new PluginHost(new IAnalyticsPlugin[]
        {
            new ThrowingPlugin(),
            new AverageAnalyticsPlugin()
        });

        var data = new List<DataPoint> { new(10.0), new(20.0) };

        // Host returns results from working plugins; faulty plugin is skipped
        var results = await host.RunAllAsync(data);

        Assert.Single(results);
        Assert.Equal("Average", results[0].PluginName);
    }

    [Fact]
    public async Task Host_WhenPluginReturnsNull_HandlesGracefully()
    {
        var host = new PluginHost(new IAnalyticsPlugin[]
        {
            new NullReturningPlugin()
        });

        var data = new List<DataPoint> { new(5.0) };

        // Null results should be filtered out -- not thrown
        var results = await host.RunAllAsync(data);
        Assert.Empty(results);
    }
}

These tests define the expected resilience behavior of your host. They're most valuable written before you implement the error handling -- they specify what "resilient" means in concrete, executable terms.

If you're building a plugin architecture using a framework like Needlr, the host's activation lifecycle adds additional failure points worth testing. See Plugin Architecture with Needlr in .NET: Building Modular Applications for how that compares to a hand-rolled approach.

Testing Plugin Versioning and Compatibility

Plugin versioning is one of the trickiest testing challenges in this space. Your contract assembly gets a new version. Old plugins compiled against v1.0 need to still work with a v1.1 host -- or fail clearly with a useful error, not a mysterious MissingMethodException at runtime.

The practical approach at the test level is to maintain separate test projects per contract version:

tests/
├── PluginHost.UnitTests/
├── PluginHost.IntegrationTests/
├── PluginContracts.V1.Tests/          -- Tests against v1 contract
└── PluginContracts.V2.CompatTests/    -- v1 plugins running against v2 host

In your compatibility test project, reference the v1 contract assembly for the plugin and the v2 host. The test validates that the host loads the v1 plugin without throwing and degrades gracefully on any missing members:

[Fact]
public async Task HostV2_LoadsV1Plugin_DoesNotThrowOnOptionalNewMethod()
{
    // Simulate a plugin compiled against v1 -- it doesn't implement new optional method
    IAnalyticsPlugin plugin = new LegacyV1AnalyticsPlugin();

    var host = new PluginHostV2(new[] { plugin });
    var data = new List<DataPoint> { new(5.0) };

    // V2 host checks for capability before calling new method -- must not crash
    var result = await host.RunWithOptionalEnrichmentAsync(data);

    Assert.NotNull(result);
}

The key design principle is that your host should never call optional contract methods without first checking for them -- either via additive interface versioning or an explicit capability check. Tests that exercise these boundaries are the fastest way to catch regressions when you evolve the contract.

A Complete Testing Example

Putting it together, a well-structured plugin test suite for a .NET project looks like this:

PluginSystem.sln
├── src/
│   ├── PluginContracts/               -- IAnalyticsPlugin, data types
│   ├── PluginHost/                    -- Discovery, loading, execution
│   └── Plugins/
│       └── AveragePlugin/             -- Concrete plugin (ships separately)
├── tests/
│   ├── PluginHost.UnitTests/          -- Discovery tests, failure scenario tests
│   ├── PluginHost.IntegrationTests/   -- End-to-end load + execute tests
│   └── PluginContracts.ContractTests/ -- Abstract base + one class per plugin
└── testplugins/
    └── TestAveragePlugin/             -- Compiled to output for integration tests

Each layer has a clear responsibility. PluginHost.UnitTests runs fast with no file system access and no DLL loading. PluginContracts.ContractTests enforces contract invariants across every concrete plugin. PluginHost.IntegrationTests loads real assemblies and verifies end-to-end behavior.

Run unit tests on every commit. Run integration tests in CI. This split keeps the local development loop fast while still catching the full class of loading and activation bugs where they belong -- in your pipeline, not in production.

Frequently Asked Questions

Should I use mocking frameworks like Moq to test plugin architectures?

Moq is useful for testing the host's error handling logic, where you need a controlled plugin stub. For the plugins themselves, prefer concrete test implementations -- they're simpler and closer to actual runtime behavior. Avoid mocking IAnalyticsPlugin in contract tests, because the entire point of those tests is to run against real implementations.

How do I test a plugin that reads from the file system or network?

Apply the same interface-extraction pattern used for discovery. Extract IFileReader or IHttpClient interfaces, inject them into the plugin constructor, and supply fakes in tests. If your plugin takes an untyped IServiceProvider, build a minimal real DI container in the test instead of mocking it -- it's less brittle and tests the actual resolution path.

Can I test plugin loading without creating real DLL files?

For unit tests of your discovery logic, yes -- use the IPluginDirectory interface shown above to return paths from a fake source that never hits disk. For tests that verify actual AssemblyLoadContext behavior, you need real assemblies. The cleanest approach is a dedicated test plugin project that compiles to your test output directory automatically via a ProjectReference in .csproj.

How do I test that a plugin implements the contract correctly?

Use the abstract contract test base class pattern. Define a base class with all contract invariants as [Fact] tests. Each plugin gets a concrete test class that inherits the base and provides the plugin instance via a protected abstract factory method. xUnit discovers and runs all inherited tests automatically -- no duplication required.

What is the best project structure for plugin architecture tests?

Separate unit tests, contract tests, and integration tests into distinct projects. Unit tests have no assembly loading or file system access and run in milliseconds. Contract tests verify each plugin against shared invariants. Integration tests load real assemblies and verify the full pipeline. Keep them separate so you can run unit tests on every save and integration tests only in CI.

Wrapping Up

Testing a testing plugin architecture C# system requires thinking in layers. Plugin implementations are just classes -- test them like any other class. Contracts need abstract test base classes to enforce invariants across all implementations without duplication. Discovery logic needs interface extraction to stay testable without hitting the file system. And the full load-discover-execute pipeline deserves a small number of carefully constructed integration tests using AssemblyLoadContext.

The most important discipline is separating fast unit tests from slow integration tests early. That split keeps your development loop clean and ensures the integration tests actually run -- in CI, on every pull request, where they can catch real problems.

For a deeper look at the design decisions that make plugin systems testable in the first place, Plugin Architecture in C# for Improved Software Design covers the structural choices that either help or hurt testability. And if you're curious how plugin-style extensibility shows up in AI tooling contexts, Build an AI Code Review Bot with Semantic Kernel in C# shows a practical application where these patterns appear in a very different form.

When you invest in testing plugin architecture properly from the start, you gain more than coverage: you get a living specification of how your system is expected to behave. Contract tests document interface invariants. Integration tests document loading scenarios. Failure tests document resilience expectations. Testing plugin architecture this way produces executable documentation that evolves alongside your code and catches regressions automatically.

Start with contract tests. Work outward to integration. Keep the failure scenarios honest.

Blazor Unit Testing Best Practices - How to Master Them for Development Success

In this article, you'll explore the importance of Blazor unit testing and learn about Blazor unit testing best practices. Get started with Blazor today!

Plugin Contracts and Interfaces in C#: Designing Extensible Plugin Systems

Design stable plugin contracts and interfaces in C# with versioning strategies and NuGet distribution. A practical guide for extensible .NET plugin systems.

Unit Testing VS Functional Testing - Dev Leader Weekly 20

Welcome to another issue of Dev Leader Weekly! In this issue I'll dive into comparing unit testing vs functional testing!

An error has occurred. This application may no longer respond until reloaded. Reload