Every test has its own lifecycle which starts with the Setup functionality and completes with Teardown functionality. In addition there may be need in the test-suite level functionality which also adheres to the Setup<->Teardown paradigm. The framework provides variety of abstractions for the end user to control the test flow at all injection points:
These interfaces are meant to be implemented by the end user and registered into their specific DI container and then injected into the testing code. Such approach provides options for simple logic reuse and is completely agnostic to the actual testing framework being used: xUnit, NUnit, MSTest etc.
There's no need in explicit registration as the framework allows for automagical registration as we will see in the next chapters.
Data
Tests are filled with some initial data - so-called arrange step and then executed to verify the system-under-test state. When you write a unit test its context has a scope and the data is passed using the objects that are created within the test itself
voidGetUsers_UsersExist_UsersAreReturned(){ var firstId =Guid.Parse("80f35b7f-1c51-49ce-a93e-152b68b061a3"); var firstUsername ="customer"; var secondId =Guid.Parse("203a72e2-0561-4c4b-8131-e55bda2975e9"); var secondUsername ="operator"; var userProviderBuilder =UserProviderBuilder.CreateBuilder(); var userDtos =new[] { newUserDto { Id = firstId, UserIdentity =newUserIdentityDto { Username = firstUsername }, },newUserDto { Id = secondId, UserIdentity =newUserIdentityDto { Username = secondUsername }, } }; userProviderBuilder.WithUsers(userDtos); RegisterInstance(((IBuilder<IUserProvider>)userProviderBuilder).Build());var service =GetRootObject(); var users =service.GetUsers().ToArray(); var firstUser =users[0]; firstUser.Id.Should().Be(firstId); firstUser.Identity.Username.Should().Be(firstUsername); firstUser.Role.Should().BeOfType<CustomerUserRole>(); var secondUser =users[1]; secondUser.Id.Should().Be(secondId); secondUser.Identity.Username.Should().Be(secondUsername); secondUser.Role.Should().BeOfType<OperatorUserRole>(); }
The UserProviderBuilder in this case is created within the test and is available to the service only within the test scope. There's no need in using an intermediate storage or other injection means to pass the data to the system under test.
However things become more complicated when we need to write an integration or end-to-end test. In the latter case the app that's being tested is executed in the scope foreign to the test scope. Hence the data should be injected somehow. Here we can use the modularity approach and pass the data using the same builders:
Whenever we're ready and have added all the required builders we can serialize them into the configured builders storage:
BuildersCollectionContext.SerializeBuilders();
This is the first part when the data is arranged and is ready to be consumed by the app. We have to add a composition module at the app end to read the stored builders:
public abstract class ProvidersModuleBase<TDependencyRegistrator> : ICompositionModule<TDependencyRegistrator>
{ public voidRegisterModule(TDependencyRegistrator dependencyRegistrator) { DeserializeBuiders(); RegisterProviders(dependencyRegistrator); } protected virtual voidDeserializeBuiders() { BuildersCollectionContext.DeserializeBuilders(); } protected abstract voidRegisterProviders(TDependencyRegistrator dependencyRegistrator); }
Here we actually de-serialize the stored builders in the app scope and have the initial system data ready.
This approach allows us to create end-to-end tests with any level of initial data complexity. The only shared part between the test code and the app code is the Dto and the ProviderBuilders libraries.
App Lifecycle
All end-to-end and some of the integration tests use the main app as the system under test. The flow is roughly the same in both cases with some differences as to what is means "to start the app". The framework contains some abstractions and implementations that allow the developer to write the app lifecycle code easily:
/// <summary> /// Represents application start/stop facade. /// </summary> publicinterfaceIApplicationFacade{ /// <summary> /// Starts the application. /// </summary> /// <paramname="startupPath">The startup path.</param> voidStart(string startupPath); /// <summary> /// Stops the application. /// </summary> voidStop(); }/// <summary> /// Represents the application path information. /// </summary> publicinterfaceIApplicationPathInfo{ /// <summary> /// The app entry point. /// </summary> string Executable { get; } /// <summary> /// The path of the app folder relatively to the test folder. /// </summary> string RelativePath { get; } }
These two interfaces provide the means of describing the application to be started and actually starting and stopping it during the test. The app could be anything: desktop, website, web app and so on.
Here you can see how in case of fake data providers we first store the builders and then start the app which has been configured to read those builders.
Modularity
When the app under test becomes more complex it starts to involve all kinds of services need to be started in order to actually run the app. They can all be bundled into the main executable/startup object or be separated to so-called app modules. In the latter case you would need an easy way to start them:
publicinterfaceIApplicationModule:IIdentifiable{string RelativePath { get; } }/// <summary> /// This interface represents an application that /// is started and stopped for each test. /// </summary> publicinterfaceIDynamicApplicationModule:IApplicationModule{ /// <summary> /// Starts the application. /// </summary> voidStart(); /// <summary> /// Stops the application. /// </summary> voidStop(); }/// <summary> /// Represents an application module that is started and stopped once /// during the whole test session. /// </summary> publicinterfaceIStaticApplicationModule:IApplicationModule{ /// <summary> /// Starts the application. /// </summary> /// <returns></returns> intStart(); /// <summary> /// Stops the application. /// </summary> /// <paramname="handle"></param> voidStop(int handle); }
All app modules are registered into the DI container and then resolved and executed in the correspondent services.
In this case the test app and the app under test reside in the same machine.
Bootstrapping
The aforementioned functionality should be invoked from with the test app bootstrapper. The bootstrapper should support modularity and extensibility. It also should include the most common midllewares: