Skip to main content

Logging

TUnit provides a flexible logging system that captures all test output and routes it to configurable destinations called "log sinks".

Basic Usage

By default, TUnit intercepts any logs to Console.WriteLine() and correlates them to the test that triggered the log using the current async context.

[Test]
public async Task MyTest()
{
Console.WriteLine("This output is captured and associated with this test");
}

Logger Objects

For more control, use TestContext.Current.GetDefaultLogger() to get a logger instance:

[Test]
public async Task MyTest()
{
var logger = TestContext.Current!.GetDefaultLogger();
logger.LogInformation("Information message");
logger.LogWarning("Warning message");
logger.LogError("Error message");
}

This logger can integrate with other logging frameworks like Microsoft.Extensions.Logging for ASP.NET applications.

Log Sinks

TUnit uses a sink-based architecture where all output is routed through registered log sinks. Each sink decides how to handle the messages - write to files, stream to IDEs, send to external services, etc.

Built-in Sinks

TUnit automatically registers these sinks based on your execution context:

SinkWhen RegisteredPurpose
TestOutputSinkAlwaysCaptures output for test results shown after execution
ConsoleOutputSink--output DetailedWrites real-time output to the console

Creating Custom Log Sinks

Implement the ILogSink interface to create a custom sink:

using TUnit.Core;
using TUnit.Core.Logging;

public class FileLogSink : ILogSink, IAsyncDisposable
{
private readonly StreamWriter _writer;

public FileLogSink(string filePath)
{
_writer = new StreamWriter(filePath, append: true);
}

public bool IsEnabled(LogLevel level)
{
// Return false to skip processing for performance
return level >= LogLevel.Information;
}

public void Log(LogLevel level, string message, Exception? exception, Context? context)
{
// Get test name from context if available
var testName = context is TestContext tc
? tc.Metadata.TestName
: "Unknown";

_writer.WriteLine($"[{DateTime.Now:HH:mm:ss}] [{level}] [{testName}] {message}");

if (exception != null)
{
_writer.WriteLine(exception.ToString());
}
}

public ValueTask LogAsync(LogLevel level, string message, Exception? exception, Context? context)
{
Log(level, message, exception, context);
return ValueTask.CompletedTask;
}

public async ValueTask DisposeAsync()
{
await _writer.FlushAsync();
await _writer.DisposeAsync();
}
}

Registering Custom Sinks

Register your sink in a [Before(TestDiscovery)] hook so it's active before any tests are discovered or run:

public class TestSetup
{
[Before(TestDiscovery)]
public static void SetupLogging()
{
// Register by instance (for sinks needing configuration)
TUnitLoggerFactory.AddSink(new FileLogSink("test-output.log"));

// Or register by type (for simple sinks)
TUnitLoggerFactory.AddSink<DebugLogSink>();
}
}

Sinks that implement IDisposable or IAsyncDisposable are automatically disposed when the test session ends.

Context Information

The context parameter provides information about where the log originated:

public void Log(LogLevel level, string message, Exception? exception, Context? context)
{
switch (context)
{
case TestContext tc:
// During test execution
var testName = tc.Metadata.TestName;
var className = tc.Metadata.TestDetails.ClassType.Name;
break;

case ClassHookContext chc:
// During [Before(Class)] or [After(Class)] hooks
var classType = chc.ClassType;
break;

case AssemblyHookContext ahc:
// During [Before(Assembly)] or [After(Assembly)] hooks
var assembly = ahc.Assembly;
break;

case null:
// Outside test execution
break;
}
}

Example: Seq/Serilog Integration

Here's an example sink that sends logs to Seq:

public class SeqLogSink : ILogSink, IDisposable
{
private readonly Serilog.ILogger _logger;

public SeqLogSink(string seqUrl)
{
_logger = new LoggerConfiguration()
.WriteTo.Seq(seqUrl)
.CreateLogger();
}

public bool IsEnabled(LogLevel level) => true;

public void Log(LogLevel level, string message, Exception? exception, Context? context)
{
var serilogLevel = level switch
{
LogLevel.Trace => Serilog.Events.LogEventLevel.Verbose,
LogLevel.Debug => Serilog.Events.LogEventLevel.Debug,
LogLevel.Information => Serilog.Events.LogEventLevel.Information,
LogLevel.Warning => Serilog.Events.LogEventLevel.Warning,
LogLevel.Error => Serilog.Events.LogEventLevel.Error,
LogLevel.Critical => Serilog.Events.LogEventLevel.Fatal,
_ => Serilog.Events.LogEventLevel.Information
};

var testName = context is TestContext tc ? tc.Metadata.TestName : "Unknown";

_logger
.ForContext("TestName", testName)
.Write(serilogLevel, exception, message);
}

public ValueTask LogAsync(LogLevel level, string message, Exception? exception, Context? context)
{
Log(level, message, exception, context);
return ValueTask.CompletedTask;
}

public void Dispose()
{
(_logger as IDisposable)?.Dispose();
}
}

Microsoft.Extensions.Logging Integration

TUnit core has no dependency on Microsoft.Extensions.Logging. If your application uses ILogger, install TUnit.AspNetCore or TUnit.Logging.Microsoft to bridge the two.

ASP.NET Core

Install the TUnit.AspNetCore package:

dotnet add package TUnit.AspNetCore

When using TestWebApplicationFactory, logging is fully automatic. See the ASP.NET Core Integration Testing docs for details.

Standalone (No ASP.NET Core)

For IHost-based apps or generic DI scenarios, install the TUnit.Logging.Microsoft package:

dotnet add package TUnit.Logging.Microsoft

Then register TUnit as a logging provider:

using TUnit.Logging.Microsoft;

var host = Host.CreateDefaultBuilder()
.ConfigureLogging(logging =>
{
logging.AddTUnit(TestContext.Current!);
})
.Build();

Or via IServiceCollection:

services.AddTUnitLogging(TestContext.Current!);

All ILogger output is routed through TUnit's console interceptor and sink pipeline, appearing in test output, IDE test explorers, and the console.

Log Levels

TUnit uses the same log level as provided to the Microsoft.Testing.Platform via command line:

dotnet run --log-level Warning

Available levels (from least to most severe):

  • Trace
  • Debug
  • Information (default)
  • Warning
  • Error
  • Critical

Custom Loggers

You can also create custom loggers by inheriting from DefaultLogger:

public class TestHeaderLogger : DefaultLogger
{
private bool _hasOutputHeader;

public TestHeaderLogger(Context context) : base(context) { }

protected override string GenerateMessage(string message, Exception? exception, LogLevel logLevel)
{
var baseMessage = base.GenerateMessage(message, exception, logLevel);

if (!_hasOutputHeader && Context is TestContext testContext)
{
_hasOutputHeader = true;
var testId = $"{testContext.Metadata.TestDetails.ClassType.Name}.{testContext.Metadata.TestName}";
return $"--- {testId} ---\n{baseMessage}";
}

return baseMessage;
}
}

Available Extension Points

  • Context - Protected property to access the associated context
  • GenerateMessage(message, exception, logLevel) - Override to customize message formatting
  • WriteToOutput(message, isError) - Override to customize synchronous output
  • WriteToOutputAsync(message, isError) - Override for asynchronous output