-
Notifications
You must be signed in to change notification settings - Fork 3
Testing Strategy and Infrastructure
Oftentimes a developer would like to perform integration tests without permanently changing test data. There are many strategies that can accomplish this objective, including use of an in-memory database.
An in-memory database is a non-relational Entity Framework store that allows normal querying of DbSets and DbQuerys and "persists" changes to DbSets when the SaveChanges or SaveChangesAsync method is called. An in-memory database is generated (and populated with test data) before each test. To setup an automated test to use an in-memory database, the developer has some options, including:
- Create a new DbContext using an in-memory provider within the test method itself (see Testing with InMemory - EF Core). This approach is helpful if the developer wishes to test Entity-Framework-related code without going through a controller.
- Create a custom WebApplicationFactory that manually overrides the DbContext configurations in the target project's startup class with an in-memory configuration (see Integration tests in ASP.NET Core).
- Within the ConfigureServices method of the Startup class, setup a HostingEnvironment-equals-Development control block that configures a DbContext to use an in-memory provider. For example,
// requires injecting IHostingEnvironment into Startup Constructor and saving as variable or property
if (HostingEnvironment.EnvironmentName == EnvironmentName.Development) {
services.AddDbContext<MyDbContext>(options => {
options.UseInMemoryDatabase("MyInMemoryDatabase");
});
}The third strategy presented above has three significant advantages over the other two strategies. First, it requires much less code in the integration tests. Second, it allows the developer to use in-memory databases for spot-testing outside of automated unit tests. Third, the same exact configuration would work regardless of how many levels of APIs are called before the database is involved. With the first two methods, the developer must either directly instantiate the DbContext or setup dependency injection for the DbContext in the web application being tested. How would the developer setup dependency injection in an API that was called by the web application being tested? Also, if an integration test involved calls to multiple endpoints (e.g., a POST and then a GET), how would the developer be able to implement the test?
On the other hand, there is at least one significant disadvantage to the third strategy -- namely, that because the database name is a constant value, it is possible that multiple tests running asynchronously in separate threads might not be truly independent. With the first two strategies, the developer could use different database names for each test, thereby avoiding any possible collisions among test execution threads.
One major objective of the EDennis.AspNetCore.Base libraries was to make integration testing and spot testing simple, consistent, and thread safe, no matter how many levels of APIs are called before Entity Framework operations are involved. The library makes use of Dictionary-based singletons for caching DbContexts in the Development environment, special HTTP request headers for telling APIs which cached context to use for a given request, and interceptors (filters) for either forwarding the special HTTP headers (when multiple levels of APIs are present) or updating the DbContext property of a repository class. The library also includes some Xunit testing base classes for simplifying the construction of test classes.
EDennis.AspNetCore.Base uses a singleton dictionary to cache DbContext instances across multiple HTTP requests. Originally, this strategy was used to cache open transactions on a real database (which could later be rolled back); however, the transaction management work and avoidance of database locks with real databases was overly complicated/restrictive. The cached-DbContext provided a convenient way to store references to multiple in-memory databases, and so this caching strategy was retained.
TestDbContextCache is a generic class whose type parameter is the specific DbContext subclass created by the developer. TestDbContextCache is a dictionary with entries whose key is the name of the database (typically, an auto-generated GUID or a default value) and whose value is a reference to an instantiated DbContext object. The cache must be instantiated as a singleton in order to maintain its data across multiple HTTP requests.
TestDbContextCache is part of the internals of the EDennis.AspNetCore.Base library. It is not intended to be used directly by developers. If the developer uses the AddDbContexts (plural) IServiceCollection extension method of the library, then TestDbContextCache will be injected as a singleton. There is no need for the developer to setup DI for this class manually.
TestDbContextCache is populated and used by two "interceptor" classes. The classes are custom middleware components that intercept HTTP methods and replace the DbContext properties of repository classes with in-memory DbContext instances. The in-memory DbContext instances are retrieved from the cache, when present, or created and added to the cache. The interceptors look for a special X-Testing-UseInMemory HTTP request header to determine which in-memory database to use. The value of the header is the in-memory database name.
The RepoInterceptor class is used for regular (non-temporal) WriteableRepo instances.
RepoInterceptor has three type parameters:
| Type Parameter | Constraints |
|---|---|
TRepo |
TRepo is the associated WriteableRepo<TEntity, TContext> class |
TEntity |
TEntity is the repo's model class |
TContext |
TContext is TRepo's DbContext class |
RepoInterceptor is added using the UseRepoInterceptor extension method for IApplicationBuilder. The call to UseRepoInterceptor should be placed within a development-environment-specific control block in Startup.Configure.
public void Configure(IApplicationBuilder app, IHostingEnvironment env) {
if (env.IsDevelopment()) {
app.UseRepoInterceptor<TodoRepo, Todo, TodoContext>();
}
}The TemporalRepoInterceptor class is used for WriteableTemporalRepo instances.
TemporalRepoInterceptor has four type parameters:
| Type Parameter | Constraints |
|---|---|
TRepo |
TRepo is the associated WriteableTemporalRepo<TEntity, TContext, THistoryContext> class |
TEntity |
TEntity is the repo's model class |
TContext |
TContext is TRepo's DbContext class for current records |
THistoryContext |
THistoryContext is TRepo's DbContext class for history records |
TemporalRepoInterceptor is added using the UseTemporalRepoInterceptor extension method for IApplicationBuilder. The call to UseTemporalRepoInterceptor should be placed within a development-environment-specific control block in Startup.Configure.
public void Configure(IApplicationBuilder app, IHostingEnvironment env) {
if (env.IsDevelopment()) {
app.UseTemporalRepoInterceptor<TodoRepo, Todo, TodoContext, TodoHistoryContext>();
}
}ApiClientInterceptor is used in those situations where the application does not have direct access to a repository class but instead indirectly accesses a repository through a child API. In those situations, the interceptor merely needs to propagate its X-Testing-UseInMemory header through the relevant ApiClient instance to the child API. ApiClientInterceptor handles propagation of this testing header.
TemporalRepoInterceptor has just one type parameter:
| Type Parameter | Constraints |
|---|---|
TClient |
TClient is the associated ApiClient class |
ApiClientInterceptor is added using the UseApiClientInterceptor extension method for IApplicationBuilder. The call to UseApiClientInterceptor should be placed within a development-environment-specific control block in Startup.Configure.
public void Configure(IApplicationBuilder app, IHostingEnvironment env) {
if (env.IsDevelopment()) {
app.UseApiClientInterceptor<TodoApi>();
}
}EDennis.AspNetCore.Base provides several base classes that simplify the creation of Xunit integration tests that involve an in-memory database. These classes are described in a separate Wiki page (Xunit Base Classes).
Development Support
- Temporal Entities
- Base Repository Classes
- Base API Controller Classes
- ApiClient and SecureApiClient
- Security Utilities
- IServiceCollection Extension Methods
- HttpClient Extension Methods
- ScopeProperties
- MigrationsExtensionsDbContextDesignTimeFactory
Testing Support
- API Launcher
- Testing Strategy and Infrastructure
- Xunit Support Classes
- Testing Security Utilities
Related Projects