Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
PR comments
  • Loading branch information
thomhurst committed Dec 21, 2025
commit 2da7ad1346675c107f0a6891c4b9bcc8116013ef
5 changes: 0 additions & 5 deletions TUnit.AspNetCore/TestWebApplicationFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,6 @@ public WebApplicationFactory<TEntryPoint> GetIsolatedFactory(
builder.ConfigureTestServices(configureServices)
.ConfigureAppConfiguration(configureConfiguration);

if (options.AddTUnitLogging)
{
builder.ConfigureTestServices(services => services.AddTUnitLogging(testContext));
}

if (options.EnableHttpExchangeCapture)
{
builder.ConfigureTestServices(services => services.AddHttpExchangeCapture());
Expand Down
14 changes: 10 additions & 4 deletions TUnit.AspNetCore/WebApplicationTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ public abstract class WebApplicationTest<TFactory, TEntryPoint> : WebApplication

private WebApplicationFactory<TEntryPoint>? _factory;

private readonly WebApplicationTestOptions _options = new();

/// <summary>
/// Gets the per-test delegating factory. This factory is isolated to the current test.
/// </summary>
Expand All @@ -94,8 +96,6 @@ public abstract class WebApplicationTest<TFactory, TEntryPoint> : WebApplication
/// </summary>
public IServiceProvider Services => Factory.Services;

protected virtual WebApplicationTestOptions Options { get; } = new();

/// <summary>
/// Initializes the isolated web application factory before each test.
/// This hook runs after all property injection is complete, ensuring
Expand All @@ -105,13 +105,15 @@ public abstract class WebApplicationTest<TFactory, TEntryPoint> : WebApplication
[EditorBrowsable(EditorBrowsableState.Never)]
public async Task InitializeFactoryAsync(TestContext testContext)
{
ConfigureTestOptions(_options);

// Run async setup first - use this for database/container initialization
await SetupAsync();

// Then create factory with sync configuration (required by ASP.NET Core hosting)
_factory = GlobalFactory.GetIsolatedFactory(
testContext,
Options,
_options,
ConfigureTestServices,
(_, config) => ConfigureTestConfiguration(config),
ConfigureWebHostBuilder);
Expand Down Expand Up @@ -162,6 +164,10 @@ protected virtual Task SetupAsync()
return Task.CompletedTask;
}

protected virtual void ConfigureTestOptions(WebApplicationTestOptions options)
{
}

/// <summary>
/// Override to configure additional services for the test.
/// Called synchronously during factory creation (ASP.NET Core requirement).
Expand Down Expand Up @@ -241,5 +247,5 @@ protected virtual void ConfigureWebHostBuilder(IWebHostBuilder builder)
/// </code>
/// </example>
public HttpExchangeCapture? HttpCapture =>
Options.EnableHttpExchangeCapture ? (field ??= new()) : null;
_options.EnableHttpExchangeCapture ? (field ??= new()) : null;
}
7 changes: 0 additions & 7 deletions TUnit.AspNetCore/WebApplicationTestOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,4 @@ public record WebApplicationTestOptions
/// Default is false.
/// </summary>
public bool EnableHttpExchangeCapture { get; set; } = false;

/// <summary>
/// Gets or sets a value indicating whether TUnit logging is added to the test's service collection.
/// When enabled, logs from the application under test are captured and written to the test output.
/// Default is true.
/// </summary>
public bool AddTUnitLogging { get; set; } = true;
}
1 change: 1 addition & 0 deletions TUnit.Example.Asp.Net.TestProject/TodoApiTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Net;
using System.Net.Http.Json;
using TUnit.AspNetCore;
using TUnit.Example.Asp.Net.Models;

namespace TUnit.Example.Asp.Net.TestProject;
Expand Down
8 changes: 7 additions & 1 deletion TUnit.Example.Asp.Net/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,20 @@

var app = builder.Build();

var logger = app.Services.GetRequiredService<ILoggerFactory>().CreateLogger("Endpoints");

if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}

app.UseHttpsRedirection();

app.MapGet("/ping", () => "Hello, World!");
app.MapGet("/ping", () =>
{
logger.LogInformation("Ping endpoint called");
return "Hello, World!";
});

// Todo CRUD endpoints
app.MapGet("/todos", async (ITodoRepository repo) =>
Expand Down
41 changes: 36 additions & 5 deletions TUnit.Example.Asp.Net/Repositories/TodoRepository.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Npgsql;
using TUnit.Example.Asp.Net.Configuration;
Expand All @@ -9,15 +10,19 @@ public class TodoRepository : ITodoRepository
{
private readonly string _connectionString;
private readonly string _tableName;
private readonly ILogger<TodoRepository> _logger;

public TodoRepository(IOptions<DatabaseOptions> options)
public TodoRepository(IOptions<DatabaseOptions> options, ILogger<TodoRepository> logger)
{
_connectionString = options.Value.ConnectionString;
_tableName = options.Value.TableName;
_logger = logger;
}

public async Task<IEnumerable<Todo>> GetAllAsync()
{
_logger.LogDebug("Fetching all todos from table {TableName}", _tableName);

var todos = new List<Todo>();

await using var connection = new NpgsqlConnection(_connectionString);
Expand All @@ -38,11 +43,14 @@ public async Task<IEnumerable<Todo>> GetAllAsync()
});
}

_logger.LogInformation("Retrieved {Count} todos from table {TableName}", todos.Count, _tableName);
return todos;
}

public async Task<Todo?> GetByIdAsync(int id)
{
_logger.LogDebug("Fetching todo {TodoId} from table {TableName}", id, _tableName);

await using var connection = new NpgsqlConnection(_connectionString);
await connection.OpenAsync();

Expand All @@ -53,20 +61,25 @@ public async Task<IEnumerable<Todo>> GetAllAsync()
await using var reader = await cmd.ExecuteReaderAsync();
if (await reader.ReadAsync())
{
return new Todo
var todo = new Todo
{
Id = reader.GetInt32(0),
Title = reader.GetString(1),
IsComplete = reader.GetBoolean(2),
CreatedAt = reader.GetDateTime(3)
};
_logger.LogInformation("Found todo {TodoId}: {Title}", todo.Id, todo.Title);
return todo;
}

_logger.LogWarning("Todo {TodoId} not found in table {TableName}", id, _tableName);
return null;
}

public async Task<Todo> CreateAsync(Todo todo)
{
_logger.LogDebug("Creating todo with title {Title} in table {TableName}", todo.Title, _tableName);

await using var connection = new NpgsqlConnection(_connectionString);
await connection.OpenAsync();

Expand All @@ -83,17 +96,22 @@ public async Task<Todo> CreateAsync(Todo todo)
await using var reader = await cmd.ExecuteReaderAsync();
await reader.ReadAsync();

return new Todo
var created = new Todo
{
Id = reader.GetInt32(0),
Title = reader.GetString(1),
IsComplete = reader.GetBoolean(2),
CreatedAt = reader.GetDateTime(3)
};

_logger.LogInformation("Created todo {TodoId}: {Title}", created.Id, created.Title);
return created;
}

public async Task<Todo?> UpdateAsync(int id, Todo todo)
{
_logger.LogDebug("Updating todo {TodoId} in table {TableName}", id, _tableName);

await using var connection = new NpgsqlConnection(_connectionString);
await connection.OpenAsync();

Expand All @@ -111,20 +129,25 @@ public async Task<Todo> CreateAsync(Todo todo)
await using var reader = await cmd.ExecuteReaderAsync();
if (await reader.ReadAsync())
{
return new Todo
var updated = new Todo
{
Id = reader.GetInt32(0),
Title = reader.GetString(1),
IsComplete = reader.GetBoolean(2),
CreatedAt = reader.GetDateTime(3)
};
_logger.LogInformation("Updated todo {TodoId}: {Title}, IsComplete={IsComplete}", updated.Id, updated.Title, updated.IsComplete);
return updated;
}

_logger.LogWarning("Failed to update todo {TodoId} - not found in table {TableName}", id, _tableName);
return null;
}

public async Task<bool> DeleteAsync(int id)
{
_logger.LogDebug("Deleting todo {TodoId} from table {TableName}", id, _tableName);

await using var connection = new NpgsqlConnection(_connectionString);
await connection.OpenAsync();

Expand All @@ -133,6 +156,14 @@ public async Task<bool> DeleteAsync(int id)
cmd.Parameters.AddWithValue("id", id);

var rowsAffected = await cmd.ExecuteNonQueryAsync();
return rowsAffected > 0;

if (rowsAffected > 0)
{
_logger.LogInformation("Deleted todo {TodoId} from table {TableName}", id, _tableName);
return true;
}

_logger.LogWarning("Failed to delete todo {TodoId} - not found in table {TableName}", id, _tableName);
return false;
}
}
48 changes: 42 additions & 6 deletions TUnit.Example.Asp.Net/Services/RedisCacheService.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
using TUnit.Example.Asp.Net.Configuration;
Expand All @@ -7,12 +8,14 @@ namespace TUnit.Example.Asp.Net.Services;
public class RedisCacheService : ICacheService, IAsyncDisposable
{
private readonly RedisOptions _options;
private readonly ILogger<RedisCacheService> _logger;
private ConnectionMultiplexer? _connection;
private IDatabase? _database;

public RedisCacheService(IOptions<RedisOptions> options)
public RedisCacheService(IOptions<RedisOptions> options, ILogger<RedisCacheService> logger)
{
_options = options.Value;
_logger = logger;
}

private async Task<IDatabase> GetDatabaseAsync()
Expand All @@ -32,27 +35,60 @@ private string GetPrefixedKey(string key) =>

public async Task<string?> GetAsync(string key)
{
var prefixedKey = GetPrefixedKey(key);
_logger.LogDebug("Getting cache key {Key}", prefixedKey);

var db = await GetDatabaseAsync();
var value = await db.StringGetAsync(GetPrefixedKey(key));
return value.HasValue ? value.ToString() : null;
var value = await db.StringGetAsync(prefixedKey);

if (value.HasValue)
{
_logger.LogInformation("Cache hit for key {Key}", prefixedKey);
return value.ToString();
}

_logger.LogDebug("Cache miss for key {Key}", prefixedKey);
return null;
}

public async Task SetAsync(string key, string value, TimeSpan? expiry = null)
{
var prefixedKey = GetPrefixedKey(key);
_logger.LogDebug("Setting cache key {Key}", prefixedKey);

var db = await GetDatabaseAsync();
await db.StringSetAsync(GetPrefixedKey(key), value, expiry);
await db.StringSetAsync(prefixedKey, value, expiry);

_logger.LogInformation("Cached value for key {Key}, expiry={Expiry}", prefixedKey, expiry?.ToString() ?? "none");
}

public async Task<bool> DeleteAsync(string key)
{
var prefixedKey = GetPrefixedKey(key);
_logger.LogDebug("Deleting cache key {Key}", prefixedKey);

var db = await GetDatabaseAsync();
return await db.KeyDeleteAsync(GetPrefixedKey(key));
var deleted = await db.KeyDeleteAsync(prefixedKey);

if (deleted)
{
_logger.LogInformation("Deleted cache key {Key}", prefixedKey);
}
else
{
_logger.LogDebug("Cache key {Key} not found for deletion", prefixedKey);
}

return deleted;
}

public async Task<bool> ExistsAsync(string key)
{
var prefixedKey = GetPrefixedKey(key);
var db = await GetDatabaseAsync();
return await db.KeyExistsAsync(GetPrefixedKey(key));
var exists = await db.KeyExistsAsync(prefixedKey);
_logger.LogDebug("Cache key {Key} exists: {Exists}", prefixedKey, exists);
return exists;
}

public async ValueTask DisposeAsync()
Expand Down
Loading