Making the Right Choice – Database Testing Strategy

You now have three capable tools in your arsenal. ADO.NET for raw control, Dapper for expressive efficiency, EF Core for full abstraction. But a test suite built by grabbing the nearest tool for each job produces something fragile and inconsistent – tests that are hard to read, slow to run, and painful to maintain. The goal isn't tool knowledge. It's principled decision-making about which tool to use when, and why.

Database testing strategy is the connective tissue that turns individual tool skills into a coherent, effective test suite. It answers the questions that tool documentation never addresses: Does this test belong in CI or only in a nightly build? Should test data setup use the same technology as the application? When does a database test actually belong in the unit test layer instead? These decisions compound across hundreds of tests – getting them right from the start saves enormous refactoring effort later.

This lesson synthesizes the "Working with Databases" block into a practical framework. You'll see a comprehensive comparison of all three approaches against dimensions that matter specifically in test automation, map common testing scenarios to concrete tool recommendations, understand where database tests belong in the test pyramid, and leave with the patterns needed to build a database testing layer that scales gracefully as your codebase grows.

The Full Comparison for Test Automation

Choosing between ADO.NET, Dapper, and EF Core for a given testing scenario comes down to trading control against convenience across several dimensions. This comparison focuses specifically on test automation concerns – which differ from application development in meaningful ways.

Comparison Matrix – Test Automation Perspective

Dimension ADO.NET Dapper EF Core
SQL Transparency Full – you write every query Full – you write every query Low – EF Core generates SQL
Setup Overhead None – built into .NET One NuGet package Multiple packages, DbContext, entities
Object Mapping Manual – you map each column Automatic by name convention Automatic with full relationship support
Test Data Creation Verbose – many parameters Concise – anonymous objects Natural – object graphs, navigation properties
Transaction Support Native – SqlTransaction Native – passes SqlTransaction Native – IDbContextTransaction
Schema Awareness None – you write schema-specific SQL None – you write schema-specific SQL Full – DbContext mirrors schema
In-Memory Testing Not supported natively SQLite in-memory with full API InMemory and SQLite providers built in
Testing App EF Code Not suitable Not suitable The natural choice
Constraint Enforcement Full – real DB only Full – real DB, SQLite in-memory too SQLite provider, real DB
Refactoring Safety Low – SQL strings break silently Low – SQL strings break silently High – compiler catches model changes
Learning Investment Low – straightforward ADO.NET Low – 6 core methods High – LINQ, change tracking, migrations

No single tool wins across all dimensions. The useful question isn't "which is best?" – it's "which trade-offs align with this specific scenario?" That reframe is the foundation of principled tool selection.

Where Test Automation Differs from App Development

Application developers choose database tools based on developer productivity, query generation quality, migration support, and performance at scale. Test engineers have a different set of priorities:

  • Speed of test execution: Tests run hundreds or thousands of times per day across the team. A 200ms overhead per test matters.
  • Data isolation: Each test must start clean. Transaction rollback patterns and per-test database creation have different implications for each tool.
  • Readability over cleverness: Test helpers are maintained by the whole team, often under time pressure. Verbose but obvious beats terse but cryptic.
  • Constraint fidelity: Tests that don't enforce database constraints give false confidence. Tool choice affects whether FK violations, unique index violations, and null constraint violations are caught.
  • Matching the application: If the application uses EF Core, there's value in test code that can interact with the same entity model – detecting breaking changes in model configuration, testing repository contracts, and seeding through the same layer the application uses.

These priorities explain why recommendations for test automation contexts often differ from the "standard" recommendations for application development. A tool that's verbose for a developer writing a web API can be perfect for a test engineer who needs to write transparent, maintainable database interactions.

Matching Scenarios to Tools

Rather than choosing one tool for everything, experienced test engineers select based on the nature of each task. Most mature test suites use two or even all three approaches – each in its natural domain.

Scenario: Test Data Setup and Teardown

Recommended: Dapper (with ADO.NET transaction management)

Creating test records, seeding reference data, and cleaning up after tests is the highest-frequency database operation in any test suite. Dapper's anonymous object parameters make this concise, and its transparent SQL keeps data creation predictable. ADO.NET transactions provide the rollback mechanism that eliminates cleanup queries entirely.

// Dapper for setup – concise, explicit, no surprise behavior
var userId = await connection.ExecuteScalarAsync<int>(
    "INSERT INTO Users (Email, FirstName, LastName, CreatedDate, IsActive) " +
    "VALUES (@Email, @FirstName, @LastName, @CreatedDate, @IsActive); " +
    "SELECT SCOPE_IDENTITY();",
    new { Email = $"test-{Guid.NewGuid():N}@example.com",
          FirstName = "Test", LastName = "User",
          CreatedDate = DateTime.UtcNow, IsActive = true },
    transaction);

ADO.NET alone works for setup too – the choice between ADO.NET and Dapper for setup is primarily ergonomic. Dapper's reduction in boilerplate is significant when your test suite creates hundreds of records across many tests.

Scenario: Validating Data After Application Operations

Recommended: Dapper

After your application creates an order, submits a payment, or updates a user profile, your test needs to verify the database state. Dapper's explicit SQL makes the assertion intent unmistakable – you're checking exactly what you wrote in the query, nothing more:

// Crystal-clear validation intent
var order = await connection.QuerySingleAsync<Order>(
    "SELECT OrderId, Status, TotalAmount FROM Orders WHERE OrderId = @Id",
    new { Id = createdOrderId }, transaction);

Assert.That(order.Status, Is.EqualTo("Confirmed"));
Assert.That(order.TotalAmount, Is.EqualTo(expectedTotal));

Using EF Core for validation after non-EF application operations introduces the risk of identity map interference (the cached entity, not the database state) and requires extra care with AsNoTracking(). Dapper reads directly from the database with no caching layer between your query and the result.

Scenario: Testing Repository and Service Classes

Recommended: EF Core (with SQLite in-memory or real database)

When your application uses EF Core for its data access layer, the most direct way to test repositories and services is to inject a test DbContext. This tests the actual LINQ queries and EF Core configuration, not a reimplementation of the logic in SQL:

// Testing EF Core application code directly – using the same DbContext type
await using var context = CreateSqliteContext();
var repo = new OrderRepository(context);

// Seed using EF Core – same path the application would use
context.Users.Add(new User { Email = "[email protected]", ... });
await context.SaveChangesAsync();

// Exercise the repository method under test
var result = await repo.GetActiveOrdersForUser(userId);

// Assert against the result
Assert.That(result.Count, Is.EqualTo(1));

Scenario: Verifying Migration Correctness

Recommended: EF Core against a real test database

When the schema defined in your migrations is the source of truth, run Migrate() against a dedicated test database and verify that schema operations produce the expected structure. This catches migration conflicts, broken index definitions, and schema drift between the EF model and the actual migration history.

Scenario: Bulk Data Creation for Performance Tests

Recommended: ADO.NET (SqlBulkCopy) or Dapper batch execution

Performance tests need large data volumes quickly. ADO.NET's SqlBulkCopy loads thousands of rows in a single operation. Dapper's batch execution with IEnumerable parameters is faster than individual inserts for moderate volumes:

// ADO.NET SqlBulkCopy for very large datasets (10,000+ rows)
using var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.Default, transaction);
bulkCopy.DestinationTableName = "Orders";
await bulkCopy.WriteToServerAsync(dataTable);  // Single database roundtrip

// Dapper batch for moderate datasets
await connection.ExecuteAsync(
    "INSERT INTO Orders (UserId, OrderDate, TotalAmount, Status) " +
    "VALUES (@UserId, @OrderDate, @TotalAmount, @Status)",
    orderList,  // Executes once per item, reuses prepared statement
    transaction);

Scenario: Cross-Cutting Validation Queries

Recommended: Dapper or ADO.NET

When you need to run aggregate checks, cross-table consistency queries, or validation queries that span multiple entities in ways your application doesn't support directly, writing explicit SQL is cleaner than bending LINQ to fit. Dapper executes these naturally:

// Verify referential integrity holds after a complex operation
var orphanedItems = await connection.QueryAsync<int>(
    "SELECT oi.OrderItemId FROM OrderItems oi " +
    "LEFT JOIN Orders o ON oi.OrderId = o.OrderId " +
    "WHERE o.OrderId IS NULL",
    transaction: transaction);

Assert.That(orphanedItems, Is.Empty,
    "No orphaned order items should exist after order deletion");

Most production test suites use Dapper as the primary tool for setup and validation, EF Core when testing EF-based application code directly, and ADO.NET for transaction management and bulk operations. This combination is not arbitrary – it reflects the natural fit of each tool with each task.

Database Tests in the Test Pyramid

The test pyramid – popularized by Mike Cohn and refined by Martin Fowler – describes the ideal distribution of tests across three layers: many fast unit tests at the base, fewer integration tests in the middle, and a small number of slow end-to-end tests at the top. Database tests don't fit neatly into one layer. Understanding where different database test styles live in the pyramid explains why some tests should run on every commit and others should run nightly.

The Bottom Layer – Unit Tests with Fakes

Pure unit tests don't touch a database at all. Business logic that could be extracted from the data access layer should be tested in isolation with mocked or faked dependencies. EF Core's InMemory provider helps here – it lets you test LINQ query logic and business rules without network calls, while accepting the trade-off that constraint enforcement is absent.

// Unit-level test – no real database, tests business logic only
[Test]
public async Task CalculateOrderTotal_AppliesDiscountCorrectly()
{
    // InMemory – fast, no constraint enforcement needed for this test
    var options = new DbContextOptionsBuilder<StoreDbContext>()
        .UseInMemoryDatabase(Guid.NewGuid().ToString())
        .Options;

    await using var context = new StoreDbContext(options);
    context.Orders.Add(new Order
    {
        UserId = 1, TotalAmount = 100m, Status = "Pending",
        OrderDate = DateTime.UtcNow
    });
    await context.SaveChangesAsync();

    var service = new DiscountService(context);
    var total = await service.CalculateWithDiscount(orderId: 1, discountPct: 10);

    Assert.That(total, Is.EqualTo(90m));
}

The Middle Layer – Integration Tests

Integration tests exercise the real interaction between your test code and the database. They verify constraint enforcement, transaction behavior, actual SQL generation, and real-world data type handling. These tests use SQLite in-memory or a dedicated SQL Server test database, run slower than unit tests, and typically execute on every CI build but may run in a dedicated test stage:

// Integration-level test – SQLite enforces constraints, tests realistic behavior
[Test]
public async Task CreateUser_DuplicateEmail_ThrowsConstraintViolation()
{
    await using var connection = new SqliteConnection("Filename=:memory:");
    await connection.OpenAsync();

    var options = new DbContextOptionsBuilder<StoreDbContext>()
        .UseSqlite(connection)
        .Options;

    await using var context = new StoreDbContext(options);
    context.Database.EnsureCreated();

    context.Users.Add(new User
        { Email = "[email protected]", FirstName = "First",
          LastName = "User", CreatedDate = DateTime.UtcNow, IsActive = true });
    await context.SaveChangesAsync();

    context.Users.Add(new User
        { Email = "[email protected]", FirstName = "Second",
          LastName = "User", CreatedDate = DateTime.UtcNow, IsActive = true });

    // SQLite enforces the unique index – this throws
    Assert.ThrowsAsync<DbUpdateException>(() => context.SaveChangesAsync());
}

The Top Layer – End-to-End Tests

End-to-end tests drive your application through its full stack – UI or API, application server, real database. The database operations happen inside the application, and your test can only observe the outcome through the same interfaces users use. You might use Dapper or ADO.NET to set up preconditions before driving through the UI, and to validate database state after the UI operation completes.

Recommended Test Distribution for Database-Heavy Systems

A mature test suite for an application with significant database interaction might distribute like this:

  • 60–70%: Unit tests with mocked dependencies or InMemory EF Core – run in milliseconds, always in local builds and CI
  • 20–30%: Integration tests with SQLite in-memory or dedicated test database – run in seconds, always in CI
  • 5–10%: End-to-end tests against a staging environment – run in minutes, in a dedicated CI stage or nightly

These proportions are guidelines, not rules. A data pipeline application will be integration-test-heavy; a UI-heavy application with thin persistence might be unit-test-heavy. Let the nature of the risk drive the distribution.

Where Each Tool Belongs in the Pyramid

The pyramid provides a natural guide for tool selection by layer:

  • Unit layer: EF Core InMemory provider, or repository mocks (Moq/NSubstitute)
  • Integration layer: EF Core with SQLite in-memory, Dapper against a dedicated test database
  • End-to-end layer: ADO.NET or Dapper for test data setup/validation against the real test environment

Keeping the test pyramid in mind when making tool choices ensures that fast tests stay fast and slow tests only run when genuinely needed. The pyramid shape emerges from this discipline – not from arbitrary quotas.

Structuring for CI/CD

A test suite that runs perfectly on a developer's machine but times out in the CI pipeline, or skips database tests entirely because no connection string is available, is a test suite that gradually loses team trust. Database tests need a deliberate CI/CD integration strategy from the start.

Connection String Management

Never hardcode connection strings. Never commit credentials to source control. Use environment-specific configuration that injects values at test runtime:

// Load connection string from environment or config file
private static string GetConnectionString()
{
    // Check environment variable first (CI/CD injects this)
    var fromEnv = Environment.GetEnvironmentVariable("TEST_DB_CONNECTION");
    if (!string.IsNullOrEmpty(fromEnv))
        return fromEnv;

    // Fall back to local config file (gitignored)
    var config = new ConfigurationBuilder()
        .AddJsonFile("appsettings.test.json", optional: true)
        .AddUserSecrets<TestAssemblyMarker>()  // .NET user secrets for local dev
        .Build();

    return config.GetConnectionString("TestDatabase")
        ?? throw new InvalidOperationException(
            "No test database connection string found. " +
            "Set the TEST_DB_CONNECTION environment variable or appsettings.test.json.");
}

In CI/CD pipelines (GitHub Actions, Azure DevOps, GitLab CI), the connection string is set as a protected secret and injected as an environment variable. Locally, developers use either appsettings.test.json (gitignored) or .NET's user secrets mechanism.

Categorizing Tests for Pipeline Stages

Separate fast and slow tests using test categories or traits. NUnit supports categories natively:

// Tag slow database integration tests
[Test]
[Category("Integration")]
[Category("Database")]
public async Task CreateOrder_PersistsCorrectly_AgainstSqlServer()
{
    // Uses real SQL Server test database
    // Runs only in CI integration stage, not on every push
}

// Fast tests – no category needed, run always
[Test]
public async Task CalculateDiscount_ReturnsCorrectAmount()
{
    // Uses InMemory provider, no network, sub-millisecond
}

Then in your CI/CD pipeline, run fast tests on every commit and defer integration tests to a dedicated stage:

// Run unit tests only (exclude integration)
// dotnet test --filter "Category!=Integration"

// Run all tests including integration
// dotnet test

// Run only integration tests
// dotnet test --filter "Category=Integration"

Database Lifecycle in CI

For CI pipelines that run integration tests against a real database, three patterns are common:

Docker container per build: Spin up a fresh SQL Server container at the start of each build. Run all migrations or EnsureCreated(). Destroy the container after tests complete. Each build is fully isolated, but startup adds time.

Shared test database with schema reset: A dedicated test database stays running permanently. At the start of each test run, apply EnsureDeleted() + EnsureCreated() (or execute a reset script). Faster startup, but parallel builds can interfere without careful isolation.

Per-test database creation: Each test creates its own database with a unique name, runs, and deletes. Maximum isolation, highest overhead – practical for integration test suites with tens of tests, impractical for hundreds.

SQL Server LocalDB for Local Development

SQL Server LocalDB ships with Visual Studio and SQL Server Developer Edition, providing a free, lightweight SQL Server instance with no service management. It's ideal for local integration testing:

// LocalDB connection string – no credentials, no service setup required
"Server=(localdb)\\MSSQLLocalDB;Database=StoreTests;Trusted_Connection=true;"

LocalDB instances start automatically when first connected to, support transactions and constraints fully, and are accessible only to the local user. For CI, switch to a full SQL Server instance (Docker is a reliable choice).

Never Test Against the Production Database

It sounds obvious, but the pressure to "just run it once" against production to debug a failing test is real. Establish this rule clearly: tests run against dedicated test databases only. Production data is never touched by test automation. Enforce this at the CI/CD level by keeping production connection strings out of the test environment entirely, not just by convention.

A CI/CD strategy for database tests isn't glamorous work, but it's the difference between a test suite that teams trust and run enthusiastically versus one that's too slow, too fragile, or too hard to configure to run consistently.

Building Team Consistency

Individual mastery of three database tools doesn't automatically produce a consistent test suite. Without shared conventions, a codebase accumulates multiple competing patterns – some tests using ADO.NET transactions, others using EF Core EnsureDeleted(), others manually deleting records in teardown. Each pattern makes sense in isolation; together they create a maintenance nightmare.

Shared Base Classes

The most effective way to enforce consistency is to centralize the infrastructure decisions in shared base classes. Developers inherit from the base class and focus entirely on test logic:

// Team-wide base for all database integration tests
public abstract class DatabaseIntegrationTestBase
{
    protected SqlConnection Connection { get; private set; }
    protected SqlTransaction Transaction { get; private set; }
    protected TestDataFactory Factory { get; private set; }

    private static readonly string ConnectionString =
        Environment.GetEnvironmentVariable("TEST_DB_CONNECTION")
        ?? new ConfigurationBuilder()
            .AddJsonFile("appsettings.test.json", optional: true)
            .Build()
            .GetConnectionString("TestDatabase");

    [SetUp]
    public async Task SetupDatabase()
    {
        Connection = new SqlConnection(ConnectionString);
        await Connection.OpenAsync();
        Transaction = Connection.BeginTransaction();
        Factory = new TestDataFactory(Connection, Transaction);
    }

    [TearDown]
    public void CleanupDatabase()
    {
        Transaction?.Rollback();
        Transaction?.Dispose();
        Connection?.Close();
        Connection?.Dispose();
    }

    // Wrapped Dapper helpers – transaction always included
    protected Task<T> QuerySingle<T>(string sql, object param = null) =>
        Connection.QuerySingleAsync<T>(sql, param, Transaction);

    protected Task<IEnumerable<T>> Query<T>(string sql, object param = null) =>
        Connection.QueryAsync<T>(sql, param, Transaction);

    protected Task<T> ExecuteScalar<T>(string sql, object param = null) =>
        Connection.ExecuteScalarAsync<T>(sql, param, Transaction);

    protected Task Execute(string sql, object param = null) =>
        Connection.ExecuteAsync(sql, param, Transaction);
}

// EF Core-specific base for tests that interact with application repositories
public abstract class EfIntegrationTestBase : IDisposable
{
    private readonly SqliteConnection _connection;
    protected StoreDbContext Context { get; private set; }

    protected EfIntegrationTestBase()
    {
        _connection = new SqliteConnection("Filename=:memory:");
        _connection.Open();

        var options = new DbContextOptionsBuilder<StoreDbContext>()
            .UseSqlite(_connection)
            .Options;

        Context = new StoreDbContext(options);
        Context.Database.EnsureCreated();
    }

    // Create a second context pointing at the same database (for verification)
    protected StoreDbContext CreateVerifyContext() =>
        new StoreDbContext(new DbContextOptionsBuilder<StoreDbContext>()
            .UseSqlite(_connection)
            .Options);

    public void Dispose()
    {
        Context.Dispose();
        _connection.Dispose();
    }
}

The Test Data Factory Pattern

A shared test data factory prevents duplicated creation logic and provides a single update point when schemas change. Every team member creating a user calls Factory.CreateUser() – not an inline INSERT they crafted themselves:

// Shared factory in the test project's core library
public class TestDataFactory
{
    private readonly IDbConnection _connection;
    private readonly IDbTransaction _transaction;

    public TestDataFactory(IDbConnection connection, IDbTransaction transaction = null)
    {
        _connection = connection;
        _transaction = transaction;
    }

    // Default parameter values allow concise test setup
    public async Task<int> CreateUser(
        string email = null,
        bool isActive = true,
        string firstName = "Test",
        string lastName = "User")
    {
        return await _connection.ExecuteScalarAsync<int>(
            "INSERT INTO Users (Email, FirstName, LastName, CreatedDate, IsActive) " +
            "VALUES (@Email, @FirstName, @LastName, @Now, @IsActive); " +
            "SELECT SCOPE_IDENTITY();",
            new { Email = email ?? UniqueEmail(), FirstName = firstName,
                  LastName = lastName, Now = DateTime.UtcNow, IsActive = isActive },
            _transaction);
    }

    public async Task<int> CreateOrder(
        int userId, decimal total = 100m, string status = "Pending")
    {
        return await _connection.ExecuteScalarAsync<int>(
            "INSERT INTO Orders (UserId, OrderDate, TotalAmount, Status) " +
            "VALUES (@UserId, @Now, @Total, @Status); " +
            "SELECT SCOPE_IDENTITY();",
            new { UserId = userId, Now = DateTime.UtcNow, Total = total, Status = status },
            _transaction);
    }

    private static string UniqueEmail() =>
        $"test-{Guid.NewGuid():N}@example.com";
}

Coding Conventions for Database Tests

Beyond base classes and factories, a few conventions prevent common consistency failures:

  • Always use unique values for email, SKU, and other unique columns. Generate with Guid.NewGuid() rather than hardcoding values like "[email protected]" that collide between tests when rollback is missing.
  • Name test methods by the scenario, not the query. CreateOrder_InactiveUser_ThrowsException is far more useful than TestOrderInsertion.
  • Keep SQL in tests at the SELECT level. Test data creation goes through the factory. Assertions query with explicit, narrow SELECT statements – never SELECT * in production test code.
  • One concept per test. A test that creates a user, creates an order, ships it, refunds it, and then asserts the final balance is testing too many things. If any assertion fails, you don't know which step caused it.

Shared infrastructure reduces the surface area where inconsistency can grow. When the connection management, transaction lifecycle, and data factory are owned by a base class, team members can't accidentally diverge on those fundamentals – they're free to think about the test scenario instead of the plumbing.

A Practical Adoption Path

Knowing the full picture of ADO.NET, Dapper, and EF Core is valuable – but most teams don't start with all three simultaneously. A gradual adoption path avoids overwhelming the team and keeps the existing test suite working while new patterns are introduced.

Starting from Scratch

If you're building a new test suite for an EF Core application, start with two tools:

  • EF Core with SQLite in-memory for testing the repository and service layers
  • Dapper for test data setup in integration tests against a real database

ADO.NET enters later when bulk data operations become necessary for performance testing.

Migrating an Existing Suite

If you inherit a test suite using only one approach – typically either pure EF Core or raw ADO.NET – introduce improvements incrementally. Adding Dapper to a suite that currently uses ADO.NET for everything is a low-risk change: Dapper runs on the same connection and transaction, so you can refactor one test class at a time without breaking others.

When the Application Doesn't Use EF Core

Not every application uses EF Core. If you're testing a system that uses ADO.NET or Dapper internally, there's no benefit in introducing EF Core to your test suite. Dapper for data setup and validation, ADO.NET for transaction management, is a complete and coherent stack for that context.

The Most Common Starting Point

For teams building test automation for a typical .NET web application using EF Core, this configuration serves well as a starting point:

  • Base class manages connection lifetime and transaction rollback
  • TestDataFactory using Dapper provides CreateUser(), CreateOrder(), CreateProduct()
  • Test assertions use explicit Dapper queries for database state verification
  • Repository tests use EF Core with SQLite in-memory
  • Integration tests against SQL Server run in a separate CI stage, gated behind a category filter

This isn't the only valid configuration – but it's a proven starting point that avoids the most common pitfalls and scales well as the suite grows.

Principled tool selection isn't about picking the "right" answer in the abstract – it's about making deliberate trade-offs that your whole team can maintain and build on. The best database testing strategy is the one your team actually uses consistently, understands thoroughly, and trusts enough to keep investing in.

Key Takeaways

  • No single tool wins every scenario. Dapper excels at test data setup and validation queries. EF Core is the natural choice for testing EF-based application code. ADO.NET provides the transaction management and bulk loading capabilities that underpin everything.
  • Test automation has different priorities than application development: speed of execution, data isolation, readability, and constraint fidelity all matter more than developer ergonomics or query generation quality.
  • The test pyramid shapes tool selection by layer: InMemory EF Core or mocks at the unit level, SQLite in-memory or a dedicated test database at the integration level, a real test environment at the end-to-end level.
  • Never hardcode connection strings. Inject them through environment variables in CI/CD and local config files (gitignored) or .NET user secrets in development. This is the only pattern that works safely across all environments.
  • Shared base classes and test data factories are the most effective mechanism for enforcing consistency across a team. They centralize infrastructure decisions, reduce duplication, and provide a single update point when schemas or conventions change.
  • CI/CD integration requires deliberate test categorization: fast tests (InMemory, SQLite) run on every commit, slow tests (real database) run in a dedicated stage. Use NUnit [Category] attributes and the --filter flag to control which tests run in which context.
  • Generate unique values for all constrained columns in test data (email addresses, product SKUs) using Guid.NewGuid(). This prevents unique constraint collisions even when transactions fail to roll back cleanly.
  • The goal of a database testing strategy isn't architectural elegance – it's a suite that the whole team runs confidently, understands, and maintains without friction.

Further Reading

What's Next?

You've completed the "Working with Databases" block – a substantial journey from SQL fundamentals through raw ADO.NET, Dapper, and EF Core, and now to the strategic layer that ties all three together. Database access is no longer a black box. You can read data from any direction, create it efficiently, verify it precisely, and structure a test suite that your team can maintain for years.

The Pathfinder path continues to build depth across other critical test automation domains. Upcoming blocks will push into more advanced territory: CI/CD pipeline architecture, performance and load testing patterns, API contract testing, and the organizational strategies that distinguish individual contributors from engineers who shape testing culture. Each block compounds on the foundation built here – the habit of choosing tools deliberately, testing at the right level, and writing automation that teammates can read and trust.