diff --git a/README.md b/README.md index 22bb858..6a75972 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,9 @@ Extensions for mocking [Entity Framework Core](https://github.com/dotnet/efcore) async queries like `ToListAsync`, `FirstOrDefaultAsync`, and more using popular mocking libraries such as **Moq**, **NSubstitute**, and **FakeItEasy** — all without hitting the database. + + + ❤️ If you really like the tool, please 👉 [Support the project](https://github.com/sponsors/ramantsitou) or ☕ [Buy me a coffee](https://buymeacoffee.com/romant). @@ -50,7 +53,19 @@ await query.ToListAsync(); ``` --- +## How It Works + +MockQueryable creates a custom implementation of: +- `IQueryable` +- `IAsyncEnumerable` +- `IAsyncQueryProvider` + +allowing EF Core async extensions to execute against in-memory collections. + +👷🏻‍See [Architecture](docs/ARCHITECTURE.md). + +--- ## 🚀 Getting Started ### 1. Create Test Data diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..8b31b7d --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,539 @@ +# MockQueryable Architecture Guide + +## Overview + +MockQueryable is a lightweight testing library that enables mocking of `IQueryable` and Entity Framework Core asynchronous query operations without requiring a real database. + +The library allows unit tests to execute LINQ expressions and EF Core async extensions such as: + +* `ToListAsync()` +* `FirstOrDefaultAsync()` +* `SingleOrDefaultAsync()` +* `AnyAsync()` +* `CountAsync()` +* `ContainsAsync()` + +using in-memory collections while preserving the query semantics expected by production code. + +--- + +# Problem Statement + +When testing services that depend on Entity Framework Core repositories, developers often encounter one of the following approaches: + +### Option 1: Real Database + +Pros: + +* Highest fidelity + +Cons: + +* Slow +* Infrastructure dependencies +* Harder test isolation + +### Option 2: EF Core InMemory Provider + +Pros: + +* Easy setup + +Cons: + +* Query behavior may differ from relational providers +* Not ideal for pure unit testing + +### Option 3: Mocking IQueryable + +Pros: + +* Fast +* Extendable +* Deterministic +* No infrastructure required + +Cons: + +* Native mocking frameworks do not support EF Core async queries + +MockQueryable addresses the third option by providing a fully asynchronous query provider implementation. + +--- + +# High-Level Architecture + +```text +┌─────────────────────────────────────┐ +│ Test Data Collection │ +│ List │ +└──────────────┬──────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ BuildMock() │ +│ BuildMockDbSet() │ +└──────────────┬──────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ TestAsyncEnumerableEfCore │ +│ IAsyncEnumerable │ +│ IAsyncQueryProvider │ +└──────────────┬──────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ TestQueryProvider │ +│ IQueryable │ +│ IQueryProvider │ +└──────────────┬──────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ Expression Visitor Pipeline │ +│ Expression Rewriting │ +└──────────────┬──────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ LINQ Execution Against Memory │ +└─────────────────────────────────────┘ +``` + +--- + +# Solution Components + +## MockQueryable.Core + +Core abstraction layer. + +Contains: + +### TestQueryProvider + +Responsible for: + +* Implementing `IQueryable` +* Implementing `IQueryProvider` +* Creating nested query providers +* Executing expression trees +* Compiling LINQ expressions against in-memory collections + +Key responsibilities: + +```csharp +public abstract class TestQueryProvider +``` + +This class acts as the engine of the framework. + +Execution flow: + +1. Receive expression tree +2. Apply expression visitor +3. Build lambda expression +4. Compile expression +5. Execute against in-memory data + +--- + +### TestExpressionVisitor + +Default implementation: + +```csharp +public class TestExpressionVisitor : ExpressionVisitor +``` + +Acts as a no-op visitor. + +Can be replaced with custom visitors to simulate provider-specific behavior. + +--- + +## MockQueryable.EntityFrameworkCore + +EF Core integration layer. + +Provides support for: + +* `IAsyncEnumerable` +* `IAsyncQueryProvider` +* EF Core async LINQ operators + +--- + +### TestAsyncEnumerableEfCore + +```csharp +public class TestAsyncEnumerableEfCore +``` + +Implements: + +```csharp +IAsyncEnumerable +IAsyncQueryProvider +``` + +Responsibilities: + +* Async query execution +* Async enumeration +* EF Core compatibility +* Delegating synchronous execution to TestQueryProvider + +This class is the bridge between LINQ and EF Core async APIs. + +--- + +### TestAsyncEnumerator + +Provides asynchronous iteration support. + +```csharp +public class TestAsyncEnumerator +``` + +Implements: + +```csharp +IAsyncEnumerator +``` + +Used by: + +```csharp +await foreach(...) +``` + +and EF Core async operators. + +--- + +# Mocking Framework Integrations + +The architecture separates query execution from mocking frameworks. + +```text + Core Engine + │ + ┌───────────────┼───────────────┐ + │ │ │ + ▼ ▼ ▼ + MockQueryable.Moq FakeItEasy NSubstitute +``` + +This design allows: + +* Framework independence +* Consistent behavior +* Easy future extensions + +--- + +## MockQueryable.Moq + +Provides: + +```csharp +BuildMock() +BuildMockDbSet() +``` + +extensions for Moq. + +Example: + +```csharp +var users = TestData.Users(); + +var mock = users.BuildMock(); + +repository + .Setup(x => x.GetQueryable()) + .Returns(mock); +``` + +--- + +## MockQueryable.NSubstitute + +Provides identical functionality for NSubstitute. + +```csharp +repository + .GetQueryable() + .Returns(mock); +``` + +--- + +## MockQueryable.FakeItEasy + +Provides identical functionality for FakeItEasy. + +```csharp +A.CallTo(() => repository.GetQueryable()) + .Returns(mock); +``` + +--- + +# Expression Visitor Pipeline + +One of the most powerful features of MockQueryable is expression rewriting. + +Custom expression visitors allow developers to emulate behavior normally provided by a database provider. + +Example: + +```csharp +BuildMockDbSet() +``` + +Execution flow: + +```text +Original Expression + │ + ▼ +Expression Visitor + │ + ▼ +Rewritten Expression + │ + ▼ +Compiled Lambda + │ + ▼ +Execution +``` + +--- + +## Example: EF.Functions.Like + +Production query: + +```csharp +.Where(x => + EF.Functions.Like( + x.Name, + "%john%" + )) +``` + +Normally this requires SQL translation. + +Using a custom visitor: + +```csharp +public class SampleLikeExpressionVisitor + : ExpressionVisitor +{ +} +``` + +the expression can be rewritten to: + +```csharp +x.Name.Contains("john") +``` + +allowing execution in memory. + +--- + +# Query Lifecycle + +## Step 1 + +Create data: + +```csharp +var users = new List(); +``` + +--- + +## Step 2 + +Build mock: + +```csharp +var mock = users.BuildMock(); +``` + +--- + +## Step 3 + +Inject into repository: + +```csharp +repository.Setup( + x => x.GetQueryable() +) +.Returns(mock); +``` + +--- + +## Step 4 + +Execute application code: + +```csharp +await service.GetUsers(); +``` + +--- + +## Step 5 + +Expression evaluation: + +```text +Service + ↓ +Repository + ↓ +MockQueryable + ↓ +Expression Visitor + ↓ +Compiled Query + ↓ +In-Memory Collection +``` + +--- + +# Design Principles + +## Separation of Concerns + +Core query execution is isolated from: + +* EF Core +* Moq +* NSubstitute +* FakeItEasy + +--- + +## Extensibility + +Custom providers can be added without changing the core engine. + +Examples: + +* CosmosDB-like operators +* PostgreSQL-specific functions +* Custom domain-specific LINQ extensions + +--- + +## Zero Infrastructure + +Tests run: + +* Without SQL Server +* Without PostgreSQL +* Without Docker +* Without EF Core InMemory provider + +--- + +# Performance Characteristics + +Because queries execute against in-memory collections: + +### Advantages + +* Very fast execution +* Deterministic tests +* No I/O +* No network latency + +### Limitations + +* Does not execute SQL +* Does not validate SQL translation +* Does not reproduce query plans +* Cannot detect provider-specific runtime SQL issues + +For those scenarios, integration tests should be used. + +--- + +# Recommended Testing Strategy + +| Test Type | Tool | +| ---------------- | ------------- | +| Domain Logic | MockQueryable | +| Service Layer | MockQueryable | +| Repository Logic | Real Provider | +| SQL Translation | Real Provider | +| Migrations | Real Database | +| End-to-End Tests | Real Database | + +--- + +# Extension Points + +## Custom Expression Visitor + +```csharp +public class MyVisitor : ExpressionVisitor +{ +} +``` + +Usage: + +```csharp +var mock = users + .BuildMock(); +``` + +--- + +## Custom DbSet Behavior + +Additional behavior can be configured for methods such as: + +```csharp +FindAsync() +AddAsync() +Remove() +``` + +Example: + +```csharp +mockDbSet + .Setup(x => x.FindAsync(id)) + .ReturnsAsync(entity); +``` + +--- + +# Architecture Summary + +MockQueryable is built around a simple but powerful architecture: + +1. In-memory collections act as the data source. +2. LINQ expressions are captured as expression trees. +3. Expression visitors optionally rewrite provider-specific operations. +4. Expressions are compiled and executed in memory. +5. Async EF Core APIs are emulated through custom implementations of: + + * `IAsyncEnumerable` + * `IAsyncQueryProvider` + * `IAsyncEnumerator` + +The result is a lightweight, extensible, and framework-agnostic solution for testing applications that rely on Entity Framework Core query semantics without requiring a real database. diff --git a/src/MockQueryable/MockQueryable.sln b/src/MockQueryable/MockQueryable.sln index 03834c9..03f4860 100644 --- a/src/MockQueryable/MockQueryable.sln +++ b/src/MockQueryable/MockQueryable.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 -VisualStudioVersion = 18.6.11806.211 stable +VisualStudioVersion = 18.6.11806.211 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MockQueryable.Core", "MockQueryable.Core\MockQueryable.Core.csproj", "{7C85327C-5EE5-4C74-A1E5-9B7046CA6CFE}" EndProject @@ -18,6 +18,7 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}" ProjectSection(SolutionItems) = preProject ..\..\.github\actions\dotnet-build\action.yml = ..\..\.github\actions\dotnet-build\action.yml + ..\..\docs\ARCHITECTURE.md = ..\..\docs\ARCHITECTURE.md ..\..\.github\workflows\build.yml = ..\..\.github\workflows\build.yml ..\..\.github\dependabot.yml = ..\..\.github\dependabot.yml ..\..\LICENSE = ..\..\LICENSE