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 servicesAddPlugin<T>()registers your plugin type and its dependencies from the DI container- The kernel resolves
UserProfilePluginwith the mockedIUserProfileServiceautomatically InvokeAsynccalls 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
InvokeStreamingAsyncyields expected chunks - Filter pipelines: Test custom
IFunctionInvocationFilterimplementations - 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.