I’m creating tests for our .Net 8 API. We use MySql for data storage and CQRS pattern for queries.
In my personal opinion, I shouldn’t use the real database for testing, because you will use an existing set of data (from which to do the assertions) which might have changed at any moment, and so the tests would fail in the future, but at the same time I cannot (shouldn’t) insert fake data (which would be permanent) into the real database.
My problem is I don’t know how to mock MySql database within our CQRS architecture, or how to use in-memory generated data, so I’m stuck.
In our query handlers we inject an interface with a “QueryAsync” method which implementation creates a connection to MySql for the queries using just MySqlConnection, but don’t know how to test that.
I could just mock “QueryAsync” method (the result of it) with a sample set of data, and it works, but not sure if this is the correct way to go, I mean, I’ll be generating a sample set of data as a result for the method, and then I’ll be comparing this set with itself, so the tests won’t ever fail (I guess).
In resume, which would be the way to go to test a controller get method in a cqrs architecture that uses mysql as database?
I’ll paste some relevant pieces of code in case it helps.
Program.cs:
await RunServer.RunAsync<Startup>(
args,
(builder, options) =>
{
...
options.OtherServices = () =>
{
builder.Services.AddScoped<ICloudStackDbUnitOfWork, CloudStackDbUnitOfWork>();
builder.Services.AddScoped<ICloudStackCoreConnectionFactory, MySqlCloudStackCoreConnectionFactory>();
...
};
...
},
);
ICloudStackCoreConnectionFactory:
public interface ICloudStackCoreConnectionFactory
{
//
// Summary:
// Create a new connection to the database.
//
// Parameters:
// region:
DbConnection CreateConnection(Region region);
Task<(int Count, TOut[] Data)> QuerySearchAsync<TDbItem, TOut>(IRegionRepository regionRepository, string fetchDataQuery, string? countDataQuery = null, Dictionary<string, string>? columnModelModel = null, Paging? paging = null, ColumnName[]? order = null, string[]? excludedOrder = null, Func<Region, TDbItem, TOut>? transform = null, object? queryParam = null, Func<Region, bool>? filter = null, bool skipDbPaging = false, CancellationToken ct = default(CancellationToken)) where TOut : class;
// Returns:
// Number of row affected
Task<int> ExecuteAsync(IRegionRepository regionRepository, string sql, object? @params = null, Func<Region, bool>? filter = null, CancellationToken ct = default(CancellationToken));
}
MySqlCloudStackCoreConnectionFactory (just the skeleton):
public sealed class MySqlCloudStackCoreConnectionFactory : ICloudStackCoreConnectionFactory
{
public MySqlCloudStackCoreConnectionFactory()
{
}
public DbConnection CreateConnection(Region region)
{
return new MySqlConnection(GetConnString(region));
}
public async Task<(int Count, TOut[] Data)> QuerySearchAsync<TDbItem, TOut>(IRegionRepository regionRepository, string fetchDataQuery, string? countDataQuery = null, Dictionary<string, string>? columnModelModel = null, Paging? paging = null, ColumnName[]? order = null, string[]? excludedOrder = null, Func<Region, TDbItem, TOut>? transform = null, object? param = null, Func<Region, bool>? filter = null, bool skipDbPaging = false, CancellationToken ct = default(CancellationToken)) where TOut : class
{
...
DbConnection connection = CreateConnection(region);
...
}
public async Task<int> ExecuteAsync(IRegionRepository regionRepository, string sql, object? @params = null, Func<Region, bool>? filter = null, CancellationToken ct = default(CancellationToken))
{
...
DbConnection connection = CreateConnection(region);
...
}
}
QueryHandler (CQRS):
public sealed class SearchQuery : VdcQuery<SearchResponse>
{
public SearchQuery(IdentityInfo identity, string? correlationId) :
base(identity, correlationId)
{
}
}
internal sealed class SearchQueryHandler : IQueryHandlerValidator<SearchQuery>, IVdcQueryHandler<SearchQuery, SearchResponse>
{
private readonly IRegionRepository _regionRepository;
private readonly ICloudStackCoreConnectionFactory _cloudStackCoreConnectionFactory;
public SearchQueryHandler(
ICloudStackCoreConnectionFactory cloudStackCoreConnectionFactory,
IRegionRepository regionRepository)
{
_cloudStackCoreConnectionFactory = cloudStackCoreConnectionFactory;
_regionRepository = regionRepository;
}
public async ValueTask<SearchResponse> HandleAsync(SearchQuery request, CancellationToken ct = default)
{
var sql = GetSearchQuerySql();
var regionFilter = GetFilters(request.Filter, sql);
var (count, data) = await _cloudStackCoreConnectionFactory.QuerySearchAsync<SearchResponse.Account, SearchResponse.Account>(
_regionRepository,
sql.fetch.ToString(),
sql.count.ToString(),
paging: request.Paging,
order: request.Order,
filter: regionFilter.filters,
transform: (regionId, item) =>
{
item.RegionId = regionId;
return item;
},
queryParam: regionFilter.param,
ct: ct
);
return new SearchResponse(count, data);
}
public ValueTask<IResponse> ValidatorAsync(SearchQuery request, RequestValidator<SearchQuery> validator, CancellationToken ct = default)
{
...
}
/// <summary>
/// Sql query and count
/// </summary>
/// <returns></returns>
private (StringBuilder fetch, StringBuilder count) GetSearchQuerySql()
{
var fetch = new StringBuilder($"""
SELECT
UUID AS {nameof(SearchResponse.Account.Id)},
account_name AS {nameof(SearchResponse.Account.Name)}
FROM cloud.account acc
WHERE acc.state != 'disabled' AND removed IS NULL
""");
var count = new StringBuilder("""
SELECT COUNT(*) count
FROM cloud.account acc
WHERE acc.state != 'disabled' AND removed IS NULL
""");
return (fetch, count);
}
/// <summary>
/// Gets the filters for the query
/// </summary>
/// <param name="filter"></param>
/// <param name="param"></param>
/// <param name="sql"></param>
/// <returns></returns>
private (Func<Region, bool> filters, Dictionary<string, object> param) GetFilters(SearchRequest.F? filter,
(StringBuilder fetch, StringBuilder count) sql)
{
...
}
}
Unit Testing:
In this next lab pay attention on how I do mock the results of QuerySearchAsync through the implementation MySqlMock and IAccountList, because this is the part I’m not sure it’s correct according what it is expected from a database test, I mean:
I think I’m mocking a method result, and not the database as it should be.
I will always get the expected results, because in fact I’m creating the expected results.
Lab we use to initialize tests:
public sealed class Lab
{
private readonly Faker _faker;
private readonly IdentityInfo _identity;
private readonly WebApplicationFactory<Startup> _appFactory;
private readonly IApiClient _apiClient;
private readonly AuthorizeOptions _authorize;
private readonly SearchResponse.Account[] _data;
private Lab(IdentityInfo identity, WebApplicationFactory<Startup> appFactory, IApiClient apiClient, AuthorizeOptions authorize, Faker faker)
{
_faker = faker;
_identity = identity;
_appFactory = appFactory;
_apiClient = apiClient;
_authorize = authorize;
_data = [];
}
public Faker Faker => _faker;
public SearchResponse.Account[] Accounts => _data;
public IdentityInfo Identity => _identity;
public IApiClient ApiClient => _apiClient;
public AuthorizeOptions Authorize => _authorize;
public WebApplicationFactory<Startup> AppFactory => _appFactory;
public async Task<EventSend?> InitAsync(IServiceProvider provider, bool useEvents = false)
{
...
}
/// <summary>
/// Create a lab with the requirement of this project
/// </summary>
/// <param name="output"></param>
/// <param name="csApi"></param>
/// <param name="cacheZoneService"></param>
/// <returns></returns>
public static Lab Create(ITestOutputHelper output)
{
var appFactory = Vdc.Libs.AspNet.Testing.Setup.Factory<Startup, InnerDbContextRead, InnerDbContextWrite>(
out var client,
out var authorize,
out var identity,
out var faker,
role: $"{VdcSecurity.Role.Management},{VdcSecurity.Role.ManagementAdmin}",
output: output,
setup: services =>
{
services.AddSingleton<IAccountList, AccountList>();
services.AddScoped<ICloudStackCoreConnectionFactory, MySqlMock>();
}
);
return new Lab(identity, appFactory, client, authorize, faker);
}
#region Nested Classes
public class AccountList : IAccountList
{
SearchResponse.Account[] IAccountList.accounts => [
CreateAccount("Account1", AccountTypes.Managed),
CreateAccount("Account2", AccountTypes.Managed),
CreateAccount("Account3", AccountTypes.Unmanaged),
CreateAccount("Account4", AccountTypes.Internal),
CreateAccount("Account5", AccountTypes.Hybrid),
CreateAccount("Account6", AccountTypes.Hybrid),
CreateAccount("Account7", AccountTypes.Hybrid),
CreateAccount("Account8", AccountTypes.Hybrid)
];
private SearchResponse.Account CreateAccount(string accountName, AccountTypes typeId)
{
SearchResponse.Account account;
account = new SearchResponse.Account
{
Id = Guid.NewGuid(),
Name = accountName,
AccountOrder = 0,
RegionId = Vdc.Libs.Region.Europe
};
return account;
}
}
}
MySqlMock:
public interface IAccountList
{
SearchResponse.Account[] accounts { get; }
}
public class MySqlMock : ICloudStackCoreConnectionFactory
{
IAccountList _accounts;
public MySqlMock(IAccountList accounts)
{
_accounts = accounts;
}
public DbConnection CreateConnection(Vdc.Libs.Region region)
{
return new MySqlConnection();
}
public async Task<(int Count, TOut[] Data)> QuerySearchAsync<TDbItem, TOut>(
IRegionRepository regionRepository,
string fetchDataQuery,
string? countDataQuery = null,
Dictionary<string, string>? columnModelModel = null,
Paging? paging = null,
ColumnName[]? order = null,
string[]? excludedOrder = null,
Func<Vdc.Libs.Region, TDbItem, TOut>? transform = null,
object? param = null,
Func<Vdc.Libs.Region, bool>? filter = null,
bool skipDbPaging = false,
CancellationToken ct = default(CancellationToken)) where TOut : class
{
return (_accounts.accounts.Length, _accounts.accounts as TOut[]);
}
public Task<int> ExecuteAsync(IRegionRepository regionRepository, string sql, object? @params = null, Func<Vdc.Libs.Region, bool>? filter = null, CancellationToken ct = default)
{
throw new NotImplementedException();
}
}
Do you think my approach for unit testing this query handler is correct?
If not, how should I do it?