n4nAI

Semantic Kernel .NET tutorial: unit testing kernel functions

Learn to unit test Semantic Kernel .NET kernel functions with xUnit, Moq, and dependency injection — complete with runnable code and CI-ready patterns.

n4n Team3 min read611 words

Audio narration

Coming soon — every post will get a voice note here.

Unit testing kernel functions in Semantic Kernel requires understanding how the kernel resolves plugins, handles dependency injection, and executes function pipelines. Most tutorials skip the testing layer entirely, leaving you to figure out mocking strategies for IKernel, IChatCompletionService, and custom plugins on your own. This tutorial walks through a practical testing setup that runs fast, stays deterministic, and catches real regressions.

Prerequisites

  • .NET 8 SDK
  • xUnit, Moq, and Microsoft.Extensions.DependencyInjection packages
  • Basic familiarity with Semantic Kernel plugins and kernel functions

Add these packages to your test project:

dotnet add package xunit
dotnet add package xunit.runner.visualstudio
dotnet add package Moq
dotnet add package Microsoft.Extensions.DependencyInjection
dotnet add package Microsoft.SemanticKernel

The function under test

Start with a kernel function that does something realistic — retrieving a user profile and formatting a response. This example uses a plugin with a dependency on an external service, which is exactly what makes testing interesting.

// src/Plugins/UserProfilePlugin.cs
using Microsoft.SemanticKernel;
using System.ComponentModel;

public sealed class UserProfilePlugin
{
    private readonly IUserProfileService _userProfileService;

    public UserProfilePlugin(IUserProfileService userProfileService)
    {
        _userProfileService = userProfileService;
    }

    [KernelFunction("get_user_summary")]
    [Description("Retrieves a formatted user summary by ID")]
    public async Task<string> GetUserSummaryAsync(
        [Description("The user ID")] string userId,
        CancellationToken cancellationToken = default)
    {
        var profile = await _userProfileService.GetProfileAsync(userId, cancellationToken);
        return $"User: {profile.DisplayName} ({profile.Email}), Role: {profile.Role}";
    }
}

public interface IUserProfileService
{
    Task<UserProfile> GetProfileAsync(string userId, CancellationToken cancellationToken = default);
}

public sealed record UserProfile(string UserId, string DisplayName, string Email, string Role);

The plugin depends on IUserProfileService — an abstraction you can mock. The kernel function attribute makes it discoverable by Semantic Kernel’s reflection-based plugin loader.

Building a test kernel

Semantic Kernel’s Kernel class is designed for dependency injection. In tests, you want a kernel configured with your plugin and mocked dependencies, but without real HTTP clients or model connections.

// tests/UserProfilePluginTests.cs
using Microsoft.Extensions.DependencyInjection;
using Microsoft.SemanticKernel;
using Moq;
using Xunit;

public sealed class UserProfilePluginTests
{
    private readonly Kernel _kernel;
    private readonly Mock<IUserProfileService> _mockUserProfileService;

    public UserProfilePluginTests()
    {
        _mockUserProfileService = new Mock<IUserProfileService>();

        var services = new ServiceCollection();
        services.AddSingleton(_mockUserProfileService.Object);
        services.AddKernel();
        services.AddPlugin<UserProfilePlugin>();

        var serviceProvider = services.BuildServiceProvider();
        _kernel = serviceProvider.GetRequiredService<Kernel>();
    }

    [Fact]
    public async Task GetUserSummaryAsync_ReturnsFormattedSummary_WhenProfileExists()
    {
        // Arrange
        var expectedProfile = new UserProfile("user-123", "Jane Doe", "jane@example.com", "Admin");
        _mockUserProfileService
            .Setup(s => s.GetProfileAsync("user-123", It.IsAny<CancellationToken>()))
            .ReturnsAsync(expectedProfile);

        // Act
        var result = await _kernel.InvokeAsync<string>("UserProfilePlugin", "get_user_summary", new() { ["userId"] = "user-123" });

        // Assert
        Assert.Equal("User: Jane Doe (jane@example.com), Role: Admin", result);
        _mockUserProfileService.Verify(s => s.GetProfileAsync("user-123", It.IsAny<CancellationToken>()), Times.Once);
    }
}

Key points in this setup:

  • AddKernel() registers the core kernel services
  • AddPlugin<T>() registers your plugin type and its dependencies from the DI container
  • The kernel resolves UserProfilePlugin with the mocked IUserProfileService automatically
  • InvokeAsync calls the function by plugin name and function name — no reflection needed in test code

Run the test to verify the baseline passes:

dotnet test --filter "FullyQualifiedName~UserProfilePluginTests.GetUserSummaryAsync_ReturnsFormattedSummary"

Expected output:

Passed!  - Failed:     0, Passed:     1, Skipped:     0, Total:     1

Testing error handling

Kernel functions should handle service failures gracefully. Test both the exception path and any fallback behavior.

[Fact]
public async Task GetUserSummaryAsync_ThrowsKernelException_WhenServiceFails()
{
    // Arrange
    _mockUserProfileService
        .Setup(s => s.GetProfileAsync("user-404", It.IsAny<CancellationToken>()))
        .ThrowsAsync(new HttpRequestException("Service unavailable"));

    // Act & Assert
    var exception = await Assert.ThrowsAsync<KernelException>(async () =>
        await _kernel.InvokeAsync<string>("UserProfilePlugin", "get_user_summary", new() { ["userId"] = "user-404" }));

    Assert.Contains("Service unavailable", exception.Message);
    _mockUserProfileService.Verify(s => s.GetProfileAsync("user-404", It.IsAny<CancellationToken>()), Times.Once);
}

Semantic Kernel wraps unhandled exceptions in KernelException. If your function catches exceptions and returns a fallback string, test that path instead:

[KernelFunction("get_user_summary_safe")]
[Description("Retrieves a formatted user summary by ID with fallback")]
public async Task<string> GetUserSummarySafeAsync(
    [Description("The user ID")] string userId,
    CancellationToken cancellationToken = default)
{
    try
    {
        var profile = await _userProfileService.GetProfileAsync(userId, cancellationToken);
        return $"User: {profile.DisplayName} ({profile.Email}), Role: {profile.Role}";
    }
    catch (Exception ex)
    {
        return $"User summary unavailable: {ex.Message}";
    }
}
[Fact]
public async Task GetUserSummarySafeAsync_ReturnsFallback_WhenServiceFails()
{
    // Arrange
    _mockUserProfileService
        .Setup(s => s.GetProfileAsync("user-404", It.IsAny<CancellationToken>()))
        .ThrowsAsync(new HttpRequestException("Service unavailable"));

    // Act
    var result = await _kernel.InvokeAsync<string>("UserProfilePlugin", "get_user_summary_safe", new() { ["userId"] = "user-404" });

    // Assert
    Assert.StartsWith("User summary unavailable:", result);
    Assert.Contains("Service unavailable", result);
}

Testing with kernel arguments and cancellation

Functions that accept complex arguments or respect cancellation tokens need targeted tests.

[KernelFunction("get_users_by_role")]
[Description("Retrieves users filtered by role")]
public async Task<string[]> GetUsersByRoleAsync(
    [Description("Role to filter by")] string role,
    [Description("Maximum results")] int maxResults = 10,
    CancellationToken cancellationToken = default)
{
    var users = await _userProfileService.GetUsersByRoleAsync(role, maxResults, cancellationToken);
    return users.Select(u => u.DisplayName).ToArray();
}
[Fact]
public async Task GetUsersByRoleAsync_RespectsMaxResults()
{
    // Arrange
    var users = Enumerable.Range(1, 15)
        .Select(i => new UserProfile($"user-{i}", $"User {i}", $"user{i}@example.com", "Member"))
        .ToArray();

    _mockUserProfileService
        .Setup(s => s.GetUsersByRoleAsync("Member", 5, It.IsAny<CancellationToken>()))
        .ReturnsAsync(users.Take(5).ToArray());

    // Act
    var result = await _kernel.InvokeAsync<string[]>("UserProfilePlugin", "get_users_by_role", new()
    {
        ["role"] = "Member",
        ["maxResults"] = 5
    });

    // Assert
    Assert.Equal(5, result.Length);
    Assert.Equal("User 1", result[0]);
}

[Fact]
public async Task GetUsersByRoleAsync_RespectsCancellation()
{
    // Arrange
    var cts = new CancellationTokenSource();
    _mockUserProfileService
        .Setup(s => s.GetUsersByRoleAsync("Member", 10, It.IsAny<CancellationToken>()))
        .Returns(async (string _, int _, CancellationToken ct) =>
        {
            await Task.Delay(1000, ct);
            return Array.Empty<UserProfile>();
        });

    cts.Cancel();

    // Act & Assert
    await Assert.ThrowsAsync<OperationCanceledException>(async () =>
        await _kernel.InvokeAsync<string[]>("UserProfilePlugin", "get_users_by_role", new()
        {
            ["role"] = "Member",
            ["maxResults"] = 10
        }, cancellationToken: cts.Token));
}

The KernelArguments dictionary accepts both required and optional parameters. Cancellation tokens flow through the kernel invocation automatically when you pass them to InvokeAsync.

Testing prompt functions

Semantic Kernel also supports prompt functions defined inline or loaded from YAML. These require a chat completion service, which you should mock for unit tests.

// tests/PromptFunctionTests.cs
using Microsoft.Extensions.DependencyInjection;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
using Moq;
using Xunit;

public sealed class PromptFunctionTests
{
    private readonly Kernel _kernel;
    private readonly Mock<IChatCompletionService> _mockChatCompletion;

    public PromptFunctionTests()
    {
        _mockChatCompletion = new Mock<IChatCompletionService>();

        var services = new ServiceCollection();
        services.AddSingleton(_mockChatCompletion.Object);
        services.AddKernel();

        var serviceProvider = services.BuildServiceProvider();
        _kernel = serviceProvider.GetRequiredService<Kernel>();

        // Register a prompt function
        _kernel.Plugins.AddFromFunctions("Summarizer", new[]
        {
            KernelFunctionFactory.CreateFromPrompt(
                "{{$input}}\n\nSummarize in one sentence:",
                functionName: "summarize",
        });
    }

    [Fact]
    public async Task Summarize_ReturnsModelResponse()
    {
        // Arrange
        var expectedSummary = "The user requested a summary of the document.";
        _mockChatCompletion
            .Setup(s => s.GetChatMessageContentsAsync(
                It.IsAny<ChatHistory>(),
                It.IsAny<PromptExecutionSettings>(),
                It.IsAny<Kernel>(),
                It.IsAny<CancellationToken>()))
            .ReturnsAsync([new ChatMessageContent(AuthorRole.Assistant, expectedSummary)]);

        // Act
        var result = await _kernel.InvokeAsync<string>("Summarizer", "summarize", new() { ["input"] = "Long document text here..." });

        // Assert
        Assert.Equal(expectedSummary, result);
    }
}

Mock IChatCompletionService and return a ChatMessageContent with the expected response. The prompt function executes through the same kernel pipeline as native functions.

Integration-style tests with real services

Unit tests with mocks are fast but don’t catch integration issues. Add a separate test project for integration tests that spin up real dependencies (Testcontainers for databases, local model runners, etc.).

// tests/Integration/UserProfileIntegrationTests.cs
using Microsoft.Extensions.DependencyInjection;
using Microsoft.SemanticKernel;
using Testcontainers.PostgreSql;
using Xunit;

public sealed class UserProfileIntegrationTests : IAsyncLifetime
{
    private readonly PostgreSqlContainer _db = new PostgreSqlBuilder().Build();
    private Kernel _kernel = null!;
    private IServiceProvider _services = null!;

    public async Task InitializeAsync()
    {
        await _db.StartAsync();

        var services = new ServiceCollection();
        services.AddDbContext<AppDbContext>(o => o.UseNpgsql(_db.GetConnectionString()));
        services.AddScoped<IUserProfileService, EfUserProfileService>();
        services.AddKernel();
        services.AddPlugin<UserProfilePlugin>();

        _services = services.BuildServiceProvider();
        _kernel = _services.GetRequiredService<Kernel>();

        // Seed test data
        using var scope = _services.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        await db.Database.EnsureCreatedAsync();
        db.Users.Add(new UserEntity { UserId = "int-1", DisplayName = "Integration User", Email = "int@test.com", Role = "Tester" });
        await db.SaveChangesAsync();
    }

    public async Task DisposeAsync() => await _db.DisposeAsync();

    [Fact]
    public async Task GetUserSummaryAsync_WorksWithRealDatabase()
    {
        var result = await _kernel.InvokeAsync<string>("UserProfilePlugin", "get_user_summary", new() { ["userId"] = "int-1" });
        Assert.Equal("User: Integration User (int@test.com), Role: Tester", result);
    }
}

Run integration tests separately in CI:

dotnet test --filter "FullyQualifiedName~Integration"

Common pitfalls

Forgetting to register the plugin with DI. services.AddPlugin<UserProfilePlugin>() must come after all its dependencies are registered. Order matters.

Mocking the wrong abstraction. Mock IUserProfileService, not UserProfilePlugin. The kernel creates the plugin instance.

Not awaiting the kernel invocation. InvokeAsync returns a FunctionResult that must be awaited or unwrapped with GetValue<T>().

Sharing kernel instances across tests. Each test should get a fresh kernel or you’ll leak state between tests. The constructor-per-test pattern shown above handles this.

Testing implementation details. Verify the output contract, not internal method calls. The Verify calls in these examples are acceptable because they confirm the service boundary was crossed — but prefer output assertions where possible.

Running in CI

Add a test stage to your pipeline:

# .github/workflows/test.yml
name: Test

on: [push, pull_request]

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.0.x'
      - run: dotnet test --configuration Release --no-build --filter "FullyQualifiedName!~Integration"

  integration-tests:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: postgres
        ports: ["5432:5432"]
        options: >-
          --health-cmd "pg_isready"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.0.x'
      - run: dotnet test --configuration Release --no-build --filter "FullyQualifiedName~Integration"

The unit tests run without external dependencies. Integration tests spin up PostgreSQL via GitHub Actions services.

What to test next

  • Function chaining: Test pipelines where one kernel function calls another
  • Streaming responses: Verify InvokeStreamingAsync yields expected chunks
  • Filter pipelines: Test custom IFunctionInvocationFilter implementations
  • Plugin discovery: Test kernel.ImportPluginFromType<T>() vs DI registration

The patterns here scale from single-function unit tests to full integration suites. Keep unit tests fast and deterministic — mock at service boundaries, not kernel internals.

Tagssemantic-kerneldotnetunit-testingfunctions

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All semantic kernel for .net enterprise apps posts →