Skip to content

Testing Strategy and Infrastructure

Dennis C. Mitchell edited this page Mar 30, 2019 · 4 revisions

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.

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:

  1. 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.
  2. 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).
  3. 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.

Testing with EDennis.AspNetCore.Base

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.

TestDbContextCache

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.

RepoInterceptor and TemporalRepoInterceptor

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.

RepoInterceptor

The RepoInterceptor class is used for regular (non-temporal) WriteableRepo instances.

Type Parameters

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

Usage in Startup.Configure

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>();
    }
}

TemporalRepoInterceptor

The TemporalRepoInterceptor class is used for WriteableTemporalRepo instances.

Type Parameters

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

Usage in Startup.Configure

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

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.

Type Parameters

TemporalRepoInterceptor has just one type parameter:

Type Parameter Constraints
TClient TClient is the associated ApiClient class

Usage in Startup.Configure

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>();
    }
}

XUnit Base Classes

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).

Clone this wiki locally