Testing Strategies
Testing is essential for ensuring the correctness and reliability of applications using Raiqub Expressions. This guide covers strategies for testing specifications, query strategies, and database sessions.
Overview
Raiqub Expressions is designed to be testable through several key approaches:
- Unit Testing - Test specifications and query strategies in isolation using in-memory collections
- Integration Testing - Test database interactions with real or test databases
- Query Translation Testing - Verify that specifications and query strategies translate correctly to SQL
- Mocking - Use mocking frameworks to isolate components
- Test Fixtures - Create reusable test data and setup
Testing Specifications
Specifications are highly testable because they encapsulate business rules in pure functions.
Unit Testing Specifications
Test specifications using the IsSatisfiedBy
method with in-memory data:
public class ProductSpecificationTests
{
[Fact]
public void IsInStock_WithPositiveQuantity_ShouldReturnTrue()
{
// Arrange
var product = new Product { AvailableQuantity = 10 };
var specification = ProductSpecification.IsInStock;
// Act
var result = specification.IsSatisfiedBy(product);
// Assert
Assert.True(result);
}
[Theory]
[InlineData(0, false)]
[InlineData(-1, false)]
[InlineData(1, true)]
[InlineData(100, true)]
public void IsInStock_VariousQuantities_ReturnsExpectedResult(
int quantity, bool expected)
{
// Arrange
var product = new Product { AvailableQuantity = quantity };
var specification = ProductSpecification.IsInStock;
// Act
var result = specification.IsSatisfiedBy(product);
// Assert
Assert.Equal(expected, result);
}
}
[TestFixture]
public class ProductSpecificationTests
{
[Test]
public void IsInStock_WithPositiveQuantity_ShouldReturnTrue()
{
// Arrange
var product = new Product { AvailableQuantity = 10 };
var specification = ProductSpecification.IsInStock;
// Act
var result = specification.IsSatisfiedBy(product);
// Assert
Assert.That(result, Is.True);
}
[TestCase(0, false)]
[TestCase(-1, false)]
[TestCase(1, true)]
[TestCase(100, true)]
public void IsInStock_VariousQuantities_ReturnsExpectedResult(
int quantity, bool expected)
{
// Arrange
var product = new Product { AvailableQuantity = quantity };
var specification = ProductSpecification.IsInStock;
// Act
var result = specification.IsSatisfiedBy(product);
// Assert
Assert.That(result, Is.EqualTo(expected));
}
}
Testing Combined Specifications
Test specification composition using logical operators:
public class CombinedSpecificationTests
{
[Fact]
public void AndSpecification_WhenBothConditionsMet_ShouldReturnTrue()
{
// Arrange
var product = new Product
{
AvailableQuantity = 10,
Price = 25.0m,
Category = "Electronics"
};
var specification = ProductSpecification.IsInStock
.And(ProductSpecification.InPriceRange(10.0m, 50.0m))
.And(ProductSpecification.InCategory("Electronics"));
// Act
var result = specification.IsSatisfiedBy(product);
// Assert
Assert.True(result);
}
[Fact]
public void OrSpecification_WhenEitherConditionMet_ShouldReturnTrue()
{
// Arrange
var product = new Product
{
AvailableQuantity = 0,
IsDiscounted = true
};
var specification = ProductSpecification.IsInStock
.Or(ProductSpecification.IsDiscounted);
// Act
var result = specification.IsSatisfiedBy(product);
// Assert
Assert.True(result); // True because IsDiscounted is true
}
[Fact]
public void NotSpecification_ShouldInvertResult()
{
// Arrange
var product = new Product { AvailableQuantity = 0 };
var specification = ProductSpecification.IsInStock.Not();
// Act
var result = specification.IsSatisfiedBy(product);
// Assert
Assert.True(result); // True because product is NOT in stock
}
}
Testing Query Translation with Entity Framework
When using Entity Framework, you can use the ToQueryString()
extension method to verify that specifications translate correctly to SQL:
public class SpecificationTranslationTests
{
private readonly TestDbContext _context;
public SpecificationTranslationTests()
{
var options = new DbContextOptionsBuilder<TestDbContext>()
.UseSqlServer("Server=(localdb)\\mssqllocaldb;Database=TestDb")
.Options;
_context = new TestDbContext(options);
}
[Fact]
public void Specification_ShouldTranslateToValidSql()
{
// Arrange
var specification = ProductSpecification.IsInStock;
// Act
var query = _context.Set<Product>().Where(specification.ToExpression());
var sql = query.ToQueryString();
// Assert
Assert.NotNull(sql);
Assert.Contains("WHERE", sql);
Assert.Contains("AvailableQuantity", sql);
_testOutputHelper.WriteLine(sql); // View generated SQL
}
[Fact]
public void CombinedSpecification_ShouldTranslateToValidSql()
{
// Arrange
var specification = ProductSpecification.IsInStock
.And(ProductSpecification.InPriceRange(10.0m, 50.0m));
// Act
var query = _context.Set<Product>().Where(specification.ToExpression());
var sql = query.ToQueryString();
// Assert
Assert.NotNull(sql);
Assert.Contains("AND", sql);
Assert.Contains("AvailableQuantity", sql);
Assert.Contains("Price", sql);
_testOutputHelper.WriteLine(sql);
}
[Fact]
public void ComplexSpecification_ShouldNotThrowTranslationException()
{
// Arrange
var specification = ProductSpecification.IsInStock
.And(ProductSpecification.InCategory("Electronics"))
.Or(ProductSpecification.IsDiscounted);
// Act & Assert
var query = _context.Set<Product>().Where(specification.ToExpression());
// Should not throw InvalidOperationException
var exception = Record.Exception(() => query.ToQueryString());
Assert.Null(exception);
}
}
Testing Query Strategies
Query strategies define query logic that can be tested independently of the database.
Unit Testing Query Strategies
Test query strategies with in-memory collections:
public class GetProductsQueryStrategyTests
{
[Fact]
public void Execute_ShouldFilterInStockProducts()
{
// Arrange
var products = new List<Product>
{
new() { Id = Guid.NewGuid(), Name = "Product A", AvailableQuantity = 10 },
new() { Id = Guid.NewGuid(), Name = "Product B", AvailableQuantity = 0 },
new() { Id = Guid.NewGuid(), Name = "Product C", AvailableQuantity = 5 }
}.AsQueryable();
var strategy = new GetProductsQueryStrategy();
// Act
var result = strategy.Execute(products).ToList();
// Assert
Assert.Equal(2, result.Count);
Assert.All(result, p => Assert.True(p.AvailableQuantity > 0));
}
[Fact]
public void Execute_ShouldProjectToDto()
{
// Arrange
var products = new List<Product>
{
new() { Id = Guid.NewGuid(), Name = "Product A", Price = 10.0m, AvailableQuantity = 5 }
}.AsQueryable();
var strategy = new GetProductDtoQueryStrategy();
// Act
var result = strategy.Execute(products).ToList();
// Assert
Assert.Single(result);
Assert.IsType<ProductDto>(result[0]);
Assert.Equal("Product A", result[0].Name);
}
[Fact]
public void Execute_ShouldApplyOrderingCorrectly()
{
// Arrange
var products = new List<Product>
{
new() { Name = "Zebra", AvailableQuantity = 10 },
new() { Name = "Apple", AvailableQuantity = 5 },
new() { Name = "Mango", AvailableQuantity = 3 }
}.AsQueryable();
var strategy = new GetProductsQueryStrategy();
// Act
var result = strategy.Execute(products).ToList();
// Assert
Assert.Equal("Apple", result[0].Name);
Assert.Equal("Mango", result[1].Name);
Assert.Equal("Zebra", result[2].Name);
}
}
Testing Query Strategy Translation with Entity Framework
Verify that query strategies translate correctly to SQL:
public class QueryStrategyTranslationTests
{
private readonly TestDbContext _context;
private readonly ITestOutputHelper _testOutputHelper;
public QueryStrategyTranslationTests(ITestOutputHelper testOutputHelper)
{
_testOutputHelper = testOutputHelper;
var options = new DbContextOptionsBuilder<TestDbContext>()
.UseSqlServer("Server=(localdb)\\mssqllocaldb;Database=TestDb")
.Options;
_context = new TestDbContext(options);
}
[Fact]
public void QueryStrategy_ShouldTranslateToValidSql()
{
// Arrange
var strategy = new GetProductsQueryStrategy();
var source = _context.Set<Product>().AsQueryable();
// Act
var query = strategy.Execute(source);
var sql = query.ToQueryString();
// Assert
Assert.NotNull(sql);
Assert.Contains("SELECT", sql);
_testOutputHelper.WriteLine(sql);
}
[Fact]
public void QueryStrategy_WithJoins_ShouldTranslateCorrectly()
{
// Arrange
var strategy = new GetOrdersWithCustomerQueryStrategy();
var querySource = new EfQuerySource(_context);
// Act
var query = strategy.Execute(querySource);
var sql = query.ToQueryString();
// Assert
Assert.Contains("JOIN", sql);
Assert.Contains("Customer", sql);
_testOutputHelper.WriteLine(sql);
}
[Fact]
public void QueryStrategy_WithComplexProjection_ShouldNotThrow()
{
// Arrange
var strategy = new GetProductWithCategoryQueryStrategy();
var source = _context.Set<Product>().AsQueryable();
// Act & Assert
var query = strategy.Execute(source);
var exception = Record.Exception(() => query.ToQueryString());
Assert.Null(exception);
}
[Fact]
public void QueryStrategy_CheckGeneratedSqlForPerformance()
{
// Arrange
var strategy = new GetProductsWithMultipleJoinsQueryStrategy();
var source = _context.Set<Product>().AsQueryable();
// Act
var query = strategy.Execute(source);
var sql = query.ToQueryString();
// Assert - Verify no N+1 queries
Assert.DoesNotContain("SELECT", sql.Substring(sql.IndexOf("FROM")));
// Assert - Verify proper JOINs instead of subqueries
var joinCount = Regex.Matches(sql, "JOIN").Count;
Assert.InRange(joinCount, 1, 3);
_testOutputHelper.WriteLine(sql);
}
}
Testing Parameterized Query Strategies
Test query strategies with different parameters:
public class SearchProductsQueryStrategyTests
{
[Theory]
[InlineData("Apple", 1)]
[InlineData("Banana", 1)]
[InlineData("Orange", 0)]
public void Execute_WithNameFilter_ShouldReturnMatchingProducts(
string nameFilter, int expectedCount)
{
// Arrange
var products = new List<Product>
{
new() { Name = "Apple" },
new() { Name = "Banana" },
new() { Name = "Apple Juice" }
}.AsQueryable();
var strategy = new SearchProductsQueryStrategy(nameFilter: nameFilter);
// Act
var result = strategy.Execute(products).ToList();
// Assert
Assert.Equal(expectedCount, result.Count);
}
[Fact]
public void Execute_WithPriceRange_ShouldFilterCorrectly()
{
// Arrange
var products = new List<Product>
{
new() { Price = 5.0m },
new() { Price = 15.0m },
new() { Price = 25.0m },
new() { Price = 35.0m }
}.AsQueryable();
var strategy = new SearchProductsQueryStrategy(minPrice: 10.0m, maxPrice: 30.0m);
// Act
var result = strategy.Execute(products).ToList();
// Assert
Assert.Equal(2, result.Count);
Assert.All(result, p => Assert.InRange(p.Price, 10.0m, 30.0m));
}
}
Testing Database Sessions
Test database sessions using integration tests with real or in-memory databases.
Integration Testing with EF Core In-Memory
Use EF Core's in-memory provider for fast integration tests:
public class ProductServiceTests : IDisposable
{
private readonly IDbSession _session;
private readonly IServiceProvider _serviceProvider;
public ProductServiceTests()
{
var services = new ServiceCollection();
// Configure in-memory database
services.AddDbContextFactory<TestDbContext>(options =>
options.UseInMemoryDatabase($"TestDb_{Guid.NewGuid()}"));
services.AddEntityFrameworkExpressions()
.AddSingleContext<TestDbContext>();
_serviceProvider = services.BuildServiceProvider();
_session = _serviceProvider.GetRequiredService<IDbSession>();
}
[Fact]
public async Task CreateProduct_ShouldPersistToDatabase()
{
// Arrange
var product = new Product
{
Id = Guid.NewGuid(),
Name = "Test Product",
Price = 10.0m,
AvailableQuantity = 5
};
// Act
_session.Add(product);
await _session.SaveChangesAsync();
// Assert
var query = _session.Query<Product>().Where(p => p.Id == product.Id);
var retrieved = await query.FirstAsync();
Assert.NotNull(retrieved);
Assert.Equal(product.Name, retrieved.Name);
}
[Fact]
public async Task Query_WithSpecification_ShouldFilterCorrectly()
{
// Arrange
_session.Add(new Product { Name = "Product 1", AvailableQuantity = 10 });
_session.Add(new Product { Name = "Product 2", AvailableQuantity = 0 });
await _session.SaveChangesAsync();
// Act
var query = _session.Query(ProductSpecification.IsInStock);
var results = await query.ToListAsync();
// Assert
Assert.Single(results);
Assert.Equal("Product 1", results[0].Name);
}
public void Dispose()
{
(_serviceProvider as IDisposable)?.Dispose();
}
}
Integration Testing with SQLite
Use SQLite for more realistic integration tests:
public class ProductServiceIntegrationTests : IAsyncLifetime
{
private readonly IDbSession _session;
private readonly IServiceProvider _serviceProvider;
private readonly SqliteConnection _connection;
public ProductServiceIntegrationTests()
{
var services = new ServiceCollection();
// Create in-memory SQLite connection
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
services.AddDbContextFactory<TestDbContext>(options =>
options.UseSqlite(_connection));
services.AddEntityFrameworkExpressions()
.AddSingleContext<TestDbContext>();
_serviceProvider = services.BuildServiceProvider();
_session = _serviceProvider.GetRequiredService<IDbSession>();
}
public async Task InitializeAsync()
{
// Create database schema
var context = _serviceProvider.GetRequiredService<IDbContextFactory<TestDbContext>>()
.CreateDbContext();
await context.Database.EnsureCreatedAsync();
}
[Fact]
public async Task Query_WithComplexSpecification_ShouldWork()
{
// Arrange
var product = new Product
{
Name = "Test Product",
Price = 25.0m,
AvailableQuantity = 10,
Category = "Electronics"
};
_session.Add(product);
await _session.SaveChangesAsync();
// Act
var specification = ProductSpecification.IsInStock
.And(ProductSpecification.InPriceRange(20.0m, 30.0m))
.And(ProductSpecification.InCategory("Electronics"));
var query = _session.Query(specification);
var results = await query.ToListAsync();
// Assert
Assert.Single(results);
}
public async Task DisposeAsync()
{
await _connection.DisposeAsync();
if (_serviceProvider is IAsyncDisposable asyncDisposable)
{
await asyncDisposable.DisposeAsync();
}
}
}
Integration Testing with Testcontainers
Use Testcontainers for testing with real databases:
public class ProductServiceDockerTests : IAsyncLifetime
{
private readonly PostgreSqlContainer _postgres;
private IServiceProvider _serviceProvider;
private IDbSession _session;
public ProductServiceDockerTests()
{
_postgres = new PostgreSqlBuilder()
.WithImage("postgres:15-alpine")
.Build();
}
public async Task InitializeAsync()
{
await _postgres.StartAsync();
var services = new ServiceCollection();
services.AddDbContextFactory<TestDbContext>(options =>
options.UseNpgsql(_postgres.GetConnectionString()));
services.AddEntityFrameworkExpressions()
.AddSingleContext<TestDbContext>();
_serviceProvider = services.BuildServiceProvider();
_session = _serviceProvider.GetRequiredService<IDbSession>();
// Create schema
var context = _serviceProvider.GetRequiredService<IDbContextFactory<TestDbContext>>()
.CreateDbContext();
await context.Database.EnsureCreatedAsync();
}
[Fact]
public async Task Query_AgainstRealPostgres_ShouldWork()
{
// Arrange
_session.Add(new Product { Name = "Test Product", AvailableQuantity = 10 });
await _session.SaveChangesAsync();
// Act
var query = _session.Query(ProductSpecification.IsInStock);
var results = await query.ToListAsync();
// Assert
Assert.Single(results);
}
public async Task DisposeAsync()
{
await _postgres.DisposeAsync();
}
}
Mocking Database Sessions
Use mocking frameworks to isolate components from database dependencies.
Mocking with Moq
public class ProductServiceTests
{
[Fact]
public async Task GetProducts_ShouldReturnProducts()
{
// Arrange
var mockSession = new Mock<IDbQuerySession>();
var mockQuery = new Mock<IDbQuery<Product>>();
var expectedProducts = new List<Product>
{
new() { Id = Guid.NewGuid(), Name = "Product 1" },
new() { Id = Guid.NewGuid(), Name = "Product 2" }
};
mockQuery
.Setup(q => q.ToListAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedProducts);
mockSession
.Setup(s => s.Query<Product>())
.Returns(mockQuery.Object);
var service = new ProductService(mockSession.Object);
// Act
var result = await service.GetProductsAsync();
// Assert
Assert.Equal(2, result.Count);
mockSession.Verify(s => s.Query<Product>(), Times.Once);
}
[Fact]
public async Task CreateProduct_ShouldCallAddAndSaveChanges()
{
// Arrange
var mockSession = new Mock<IDbSession>();
var product = new Product { Name = "New Product" };
mockSession
.Setup(s => s.SaveChangesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(1);
var service = new ProductService(mockSession.Object);
// Act
await service.CreateProductAsync(product);
// Assert
mockSession.Verify(s => s.Add(product), Times.Once);
mockSession.Verify(s => s.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
}
}
Mocking with NSubstitute
public class OrderServiceTests
{
[Fact]
public async Task GetOrder_ShouldReturnOrder()
{
// Arrange
var session = Substitute.For<IDbQuerySession>();
var query = Substitute.For<IDbQuery<Order>>();
var expectedOrder = new Order { Id = Guid.NewGuid() };
query.FirstAsync(Arg.Any<CancellationToken>())
.Returns(expectedOrder);
session.Query<Order>()
.Returns(query);
var service = new OrderService(session);
// Act
var result = await service.GetOrderAsync(expectedOrder.Id);
// Assert
Assert.Equal(expectedOrder.Id, result.Id);
await query.Received(1).FirstAsync(Arg.Any<CancellationToken>());
}
}
Test Data Builders
Create test data builders for reusable test data:
public class ProductBuilder
{
private Guid _id = Guid.NewGuid();
private string _name = "Test Product";
private decimal _price = 10.0m;
private int _availableQuantity = 10;
private string _category = "Test Category";
public ProductBuilder WithId(Guid id)
{
_id = id;
return this;
}
public ProductBuilder WithName(string name)
{
_name = name;
return this;
}
public ProductBuilder WithPrice(decimal price)
{
_price = price;
return this;
}
public ProductBuilder OutOfStock()
{
_availableQuantity = 0;
return this;
}
public ProductBuilder InStock(int quantity = 10)
{
_availableQuantity = quantity;
return this;
}
public ProductBuilder InCategory(string category)
{
_category = category;
return this;
}
public Product Build()
{
return new Product
{
Id = _id,
Name = _name,
Price = _price,
AvailableQuantity = _availableQuantity,
Category = _category
};
}
}
// Usage
public class ProductTests
{
[Fact]
public void Specification_WithBuilder_ShouldWork()
{
// Arrange
var product = new ProductBuilder()
.WithName("Electronics Item")
.InStock(5)
.InCategory("Electronics")
.Build();
// Act
var result = ProductSpecification.IsInStock.IsSatisfiedBy(product);
// Assert
Assert.True(result);
}
}
Testing Best Practices
Do's
✅ Test business logic - Focus on testing specifications and query strategies
✅ Use in-memory tests - For fast unit tests of query logic
✅ Use real databases - For critical integration tests
✅ Test SQL translation - Use ToQueryString()
to verify EF Core query generation
✅ Test edge cases - Null values, empty collections, boundary conditions
✅ Use test builders - For creating consistent test data
✅ Mock external dependencies - Isolate the code under test
✅ Test error handling - Verify exception handling and error scenarios
✅ Use descriptive test names - Make test intent clear
✅ Review generated SQL - Check for performance issues like N+1 queries
Don'ts
❌ Don't test framework code - Trust that EF Core/Marten work correctly
❌ Don't overuse mocks - Prefer integration tests for database operations
❌ Don't share state - Each test should be independent
❌ Don't test implementation details - Test behavior, not internal structure
❌ Don't skip cleanup - Always dispose resources properly
❌ Don't ignore async/await - Use proper async testing patterns
❌ Don't assume SQL correctness - Always verify with ToQueryString()
in EF tests
Testing Patterns
Arrange-Act-Assert (AAA)
Structure tests using the AAA pattern:
[Fact]
public async Task GetProducts_ReturnsInStockProducts()
{
// Arrange - Set up test data and dependencies
var product = new ProductBuilder().InStock().Build();
_session.Add(product);
await _session.SaveChangesAsync();
// Act - Execute the code under test
var query = _session.Query(ProductSpecification.IsInStock);
var results = await query.ToListAsync();
// Assert - Verify the results
Assert.Single(results);
Assert.Equal(product.Id, results[0].Id);
}
Test Fixtures
Share setup across tests using fixtures:
public class DatabaseFixture : IAsyncLifetime
{
private IServiceProvider _serviceProvider;
private AsyncServiceScope _serviceScope;
public IDbSession Session { get; private set; }
public async Task InitializeAsync()
{
var services = new ServiceCollection();
services.AddDbContextFactory<TestDbContext>(options =>
options.UseInMemoryDatabase($"TestDb_{Guid.NewGuid()}"));
services.AddEntityFrameworkExpressions().AddSingleContext<TestDbContext>();
_serviceProvider = services.BuildServiceProvider();
_serviceScope = _serviceProvider.CreateAsyncScope();
Session = _serviceScope.ServiceProvider.GetRequiredService<IDbSession>();
}
public async Task DisposeAsync()
{
await _serviceScope.DisposeAsync();
await _serviceProvider.DisposeAsync();
}
}
public class ProductTests : IClassFixture<DatabaseFixture>
{
private readonly DatabaseFixture _fixture;
public ProductTests(DatabaseFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task Test_UsesSharedFixture()
{
var query = _fixture.Session.Query<Product>();
var results = await query.ToListAsync();
Assert.NotNull(results);
}
}
See Also
- Specification - Testing specifications
- Query Strategy - Testing query strategies
- Database Session - Session usage
- Entity Framework Core - EF Core integration
- xUnit - Testing framework
- Moq - Mocking framework
- Testcontainers - Integration testing with Docker
- EF Core Testing - Official EF Core testing guide