Testing your filter classes¶
What this does¶
Filter classes deserve two layers of test coverage:
- Unit tests against an in-memory
IQueryable<T>— instantiate the generated filter class directly, build aFilterRequest, callquery.Apply(filter, request).ToList(), and assert the result. Fast, no I/O, doesn't exercise the EF translator but proves the predicate logic and the validation pipeline. - Integration tests against a real EF provider — typically SQLite in-memory (always available) and optionally Postgres / SQL Server via Testcontainers. Proves the predicate translates to SQL on the providers you ship against.
When to use¶
Every filter class should carry at least one happy-path integration test plus a handful of validation-failure scenarios. Custom operators (especially provider-specific ones like EF.Functions.ILike) need provider-specific integration tests because the translation happens at SQL generation time, not at predicate compose time.
Minimal code¶
Unit-test pattern with xUnit:
public sealed class UserFilterTests
{
[Fact]
public void Apply_AgeGreaterThan_FiltersToOlderUsers()
{
// Arrange
var users = new[]
{
new User { Id = 1, Name = "Alice", Age = 25 },
new User { Id = 2, Name = "Bob", Age = 35 },
new User { Id = 3, Name = "Carol", Age = 45 },
}.AsQueryable();
var filter = new UserFilter();
var request = new FilterRequest
{
Where = new FilterGroup(LogicalOp.And, new FilterNode[]
{
new FilterLeaf("age", "gt", JsonDocument.Parse("30").RootElement),
}),
};
// Act
var matched = users.Apply(filter, request).ToList();
// Assert
matched.Select(user => user.Id).Should().BeEquivalentTo(new[] { 2, 3 });
}
}
Integration-test pattern follows tests/Filtering.Net.EntityFrameworkCore.Tests/Scenarios/: SQLite in-memory connection plus a per-test DbContext fixture, seed via a small helper, then assert on ApplyPagedAsync output:
[Fact]
public async Task ApplyPagedAsync_NameStartsWith_ReturnsMatchingPage()
{
// Arrange
using var fixture = await SqliteFixture.CreateAsync();
await WidgetSeed.SeedAsync(fixture.DbContext);
var request = new FilterRequest
{
Where = new FilterLeaf("name", "startsWith", JsonDocument.Parse("\"Wid\"").RootElement),
Page = 1,
PageSize = 10,
};
// Act
var page = await fixture.DbContext.Widgets
.ApplyPagedAsync(fixture.WidgetFilter, request);
// Assert
page.Items.Should().NotBeEmpty();
page.TotalCount.Should().BeGreaterThan(0);
}
Variations¶
- Provider-specific scenarios — wrap operators that depend on
EF.Functions.*(e.g.ILikeon Postgres) in a Testcontainers-backed fixture so the test runs against a real Postgres. The EF Core test project shows the pattern withDockerAvailability.IsAvailable–gatedAssert.Skipblocks. - Validation-failure tests — assert the negative path with
Assert.Throws<FilterValidationException>(() => users.Apply(filter, request).ToList())and inspectexception.Result.Errorsto confirm the rightFilterValidationCodecame through. - Snapshot tests for emitted code — if you're a contributor changing the generator, the generator-test project under
tests/Filtering.Net.Generator.Tests/Emission/Snapshots/already covers the emitted-source surface; consumer apps should not snapshot generated code.
Pitfalls¶
- Generated code lives under
obj/— don't import it directly in tests. Test only the public surface:IFilterDefinition<T>for typed access, plus theApply/ApplyPagedAsyncextension methods. - Generated filter classes are named after the partial —
UserFilter, notSample.UserFilter. The namespace matches the partial's namespace. - In-memory
IQueryable<T>(LINQ-to-objects) translates predicates differently from EF. A test that passes againstusers.AsQueryable()does not prove the predicate translates to SQL — pair every meaningful operator with at least one integration test. - The auto-emitted enum profiles (
Filtering.Net.Generated.<EnumName>Filter) are emission targets you should not reference directly from test code; assert via the public filter class instead.