Testing Your Repository - lobodava/artisan-orm GitHub Wiki
A repository in Artisan.Orm is a thin layer over SqlConnection, stored procedures, and a small mapping pipeline. Mocking SqlDataReader and SqlCommand is technically possible but rarely worth the effort — the bugs that break in real life are mismatches between the C# mapper and the SQL schema, parameter type mismatches, transaction interactions, deadlock-prone procedures. Those don't show up against a mock.
Recommended approach: integration tests against a real SQL Server instance with a known seeded schema. The Artisan.Orm test suite (Tests/) is the canonical reference — read it.
Tests/
├── Tests.csproj # net8.0; net10.0 (multi-target)
├── appsettings.json # connection string for the test database
├── AppSettings.cs # binds the JSON to a typed object
├── DAL/ # repositories + models exercised by tests
│ ├── Users/
│ ├── Records/
│ ├── GrandRecords/
│ └── Folders/
├── DataServices/ # higher-level services on top of repositories
└── Tests/ # the actual [TestClass] files
├── UserRepositoryTest.cs
├── RecordRepositoryTest.cs
├── BulkCopyTest.cs
├── ReadToListsTest.cs
└── ...
The Database/ project at the repo root contains the schema, stored procedures, and seed data — published once before the test run.
Using MSTest 4:
using Artisan.Orm;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Tests.DAL.Users;
using Tests.DAL.Users.Models;
namespace Tests.Tests;
[TestClass]
public class UserRepositoryTest
{
private Repository _repository = null!;
[TestInitialize]
public void TestInitialize()
{
var appSettings = new AppSettings();
_repository = new Repository(appSettings.ConnectionStrings.DatabaseConnection);
// Reset any rows the test might create from a previous run.
_repository.ExecuteCommand(cmd =>
cmd.UseSql("delete from dbo.Users where Id > 14;"));
}
[TestCleanup]
public void TestCleanup() => _repository.Dispose();
[TestMethod]
public void GetUserById()
{
var user = _repository.GetUserById(1);
Assert.IsNotNull(user);
Assert.AreEqual(1, user.Id);
}
[TestMethod]
public async Task GetUserByIdAsync()
{
var user = await _repository.GetUserByIdAsync(1);
Assert.IsNotNull(user);
Assert.AreEqual(1, user.Id);
}
}Same pattern works with xUnit / NUnit — replace [TestInitialize] with the framework's equivalent constructor or [SetUp].
{
"ConnectionStrings": {
"DatabaseConnection": "Data Source=.\\SQLEXPRESS;Initial Catalog=Artisan;Integrated Security=True;Pooling=False;TrustServerCertificate=True;"
}
}Two notes:
-
Pooling=Falseis intentional in the test config. Without pooling, everyusing var repo = ...does a full TDS handshake — slower per-test, but it surfaces connection-leak bugs that the pool would otherwise mask. Don't copy this to production configs. -
The test database is shared, not freshly created per test. Tests assume a known seeded schema — IDs 1..14 in
dbo.Usersare reserved "hero" rows that tests don't delete. That contract lives in[TestInitialize]cleanup clauses.
Three patterns in increasing isolation cost:
| Pattern | Cost | Isolation |
|---|---|---|
[TestInitialize] cleans up just the rows it might have touched |
Cheap | Depends on tests being well-mannered |
| Each test runs inside a transaction that is always rolled back | Medium | Strong — but breaks BulkCopy & MERGE paths |
| Database is dropped & re-published before every test class | Expensive | Strongest — only worth it for migration tests |
Artisan.Orm's own tests use the cheapest pattern (per-test cleanup of rows beyond the seeded baseline). For your project, default to that and escalate only if test pollution becomes a real problem.
async Task test methods work in MSTest 4, xUnit, and NUnit. With Artisan.Orm's async API:
[TestMethod]
public async Task SaveAndRead_ConsistentByCancellationToken()
{
using var cts = new CancellationTokenSource();
var saved = await _repository.SaveUserAsync(NewUser(), cts.Token);
var read = await _repository.GetUserByIdAsync(saved.Id, cts.Token);
Assert.AreEqual(saved.Login, read.Login);
}For tests that exercise IAsyncEnumerable<T>:
[TestMethod]
public async Task StreamsAllRecords()
{
var count = 0;
await foreach (var _ in _repository.ReadToAsyncEnumerable<Record>("dbo.GetRecords"))
count++;
Assert.IsTrue(count > 0);
}MSTest 4 ships an analyzer (MSTEST0017) that flags Assert.AreEqual(actual, expected) calls — the documented order is (expected, actual). Following the analyzer keeps assertion failure messages readable: "Expected: 1, Actual: 0" instead of the inverse.
Assert.AreEqual(expected: 1, actual: user.Id); // explicit names — always safe
Assert.AreEqual(1, user.Id); // canonical positionalIf your CI runs unit tests on machines without SQL Server, gate the integration tests:
[TestClass]
public class UserRepositoryTest
{
[ClassInitialize]
public static void Init(TestContext _)
{
try
{
using var conn = new SqlConnection(
new AppSettings().ConnectionStrings.DatabaseConnection);
conn.Open();
}
catch (Exception ex)
{
Assert.Inconclusive($"Test database unreachable: {ex.Message}");
}
}
// ... [TestMethod] ...
}Or split into two test projects: a fast, DB-free Tests.Unit and an integration Tests.Integration that only runs when an env var (RUN_INTEGRATION_TESTS=1) is set.
When you change a stored procedure or a TVP, the breakage tends to manifest at runtime, not at compile time. A [ClassInitialize] that runs the read-side methods on every entity once gives you a quick "schema and mappers are in sync" signal:
[ClassInitialize]
public static void SmokeTest(TestContext _)
{
using var repo = new Repository(connStr);
// Touches every CreateObject mapper + matching SELECT shape.
_ = repo.GetUserById(1);
_ = repo.GetRecords();
_ = repo.GetFolderTree(1);
// ... one read per entity ...
}If a column was renamed and the mapper was not updated, this fails fast on test class init instead of randomly somewhere inside individual tests.
See also:
- Test suite in the repo
- Database project in the repo
- About Connection Strings
-
Performance Notes —
Pooling=Truematters in production