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.

Test-suite reference layout

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.

Minimal test-class skeleton

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].

appsettings.json for tests

{
    "ConnectionStrings": {
        "DatabaseConnection": "Data Source=.\\SQLEXPRESS;Initial Catalog=Artisan;Integrated Security=True;Pooling=False;TrustServerCertificate=True;"
    }
}

Two notes:

  • Pooling=False is intentional in the test config. Without pooling, every using 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.Users are reserved "hero" rows that tests don't delete. That contract lives in [TestInitialize] cleanup clauses.

Per-test cleanup vs. fixture cleanup

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 test patterns

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);
}

Asserting the canonical (expected, actual) order

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 positional

Skipping tests when the DB is unreachable

If 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.

Smoke test before publishing the schema

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:

⚠️ **GitHub.com Fallback** ⚠️