Testing

Test Lifecycle

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:

public interface ISetupService    
{      
    void Setup();
}

public interface ITeardownService    
{ 
    void Teardown();
}

public interface ILifecycleService : ISetupService, ITeardownService    
{
}

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

void GetUsers_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[] 
    {    new UserDto                
         {                    
             Id = firstId,                    
             UserIdentity = new UserIdentityDto                    
             {                        
                 Username = firstUsername                    
             },
         },
         new UserDto                
         {                    
             Id = secondId,                    
             UserIdentity = new UserIdentityDto                    
             {                        
                 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:

public interface IBuilderRegistrationService    
{       
    void RegisterBuilder<TService>(IBuilder<TService> builder) where TService : class;    
}

public class BuilderRegistrationService : IBuilderRegistrationService    
{  
    public void RegisterBuilder<TService>(IBuilder<TService> builder) where TService : class        
    {            
        BuildersCollectionContext.AddBuilder(builder);        
    }    
}

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 void RegisterModule(TDependencyRegistrator dependencyRegistrator)        
    {            
        DeserializeBuiders();            
        RegisterProviders(dependencyRegistrator);                    
    }       
    
    protected virtual void DeserializeBuiders()        
    {            
        BuildersCollectionContext.DeserializeBuilders();        
    }
            
    protected abstract void RegisterProviders(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>    
public interface IApplicationFacade    
{        
    /// <summary>        
    /// Starts the application.        
    /// </summary>        
    /// <param name="startupPath">The startup path.</param>        
    void Start(string startupPath);        
    
    /// <summary>        
    /// Stops the application.        
    /// </summary>        
    void Stop();    
}

/// <summary>    
/// Represents the application path information.    
/// </summary>    
public interface IApplicationPathInfo    
{        
    /// <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.

public interface IStartApplicationService    
{        
    void Start(string startupPath);    
}

public abstract class StartApplicationService : IStartApplicationService    
{
    public class WithFakeProviders : StartApplicationService        
    {             
        private readonly IApplicationFacade _applicationFacade; 
        
        public WithFakeProviders(IApplicationFacade applicationFacade)    
        {                
            _applicationFacade = applicationFacade;            
        }
        
        public override void Start(string startupPath)            
        {                
            BuildersCollectionContext.SerializeBuilders();                
            _applicationFacade.Start(startupPath);           
        }        
    }

    public class WithRealProviders : StartApplicationService        
    {            
        private readonly IApplicationFacade _applicationFacade; 
    
        public WithRealProviders(IApplicationFacade applicationFacade)            
        {                
            _applicationFacade = applicationFacade;            
        } 
    
        public override void Start(string startupPath)            
        {                
            _applicationFacade.Start(startupPath);            
        }        
    } 
    
    public abstract void Start(string startupPath);   
}

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:

public interface IApplicationModule : IIdentifiable    
{
    string RelativePath { get; }           
}

/// <summary>    
/// This interface represents an application that    
/// is started and stopped for each test.    
/// </summary>    
public interface IDynamicApplicationModule : IApplicationModule    
{                
    /// <summary>        
    /// Starts the application.        
    /// </summary>        
    void Start();   
         
    /// <summary>        
    /// Stops the application.        
    /// </summary>        
    void Stop();    
}

/// <summary>    
/// Represents an application module that is started and stopped once    
/// during the whole test session.    
/// </summary>    
public interface IStaticApplicationModule : IApplicationModule    
{        
    /// <summary>        
    /// Starts the application.        
    /// </summary>        
    /// <returns></returns>        
    int Start();        
    
    /// <summary>        
    /// Stops the application.        
    /// </summary>        
    /// <param name="handle"></param>        
    void Stop(int handle);    
}

All app modules are registered into the DI container and then resolved and executed in the correspondent services.

public sealed class StaticSetupService : IStaticSetupService    
{        
    private static bool _isOneTimeSetupHandled = false;        
    private static Dictionary<int, IStaticApplicationModule> _handlesMap = 
        new Dictionary<int, IStaticApplicationModule>();        
        
    private static readonly object OneTimeSetupSyncObject = new object();
    private readonly IDependencyResolver _dependencyResolver;        
    private readonly IStartStaticApplicationModuleService _startStaticApplicationModuleService;        
        
    public StaticSetupService(IDependencyResolver dependencyResolver,
                IStartStaticApplicationModuleService startStaticApplicationModuleService)        
    {            
        _dependencyResolver = dependencyResolver;
        _startStaticApplicationModuleService = startStaticApplicationModuleService;
    }       
    
    public Dictionary<int, IStaticApplicationModule> OneTimeSetup()        
    {            
        lock (OneTimeSetupSyncObject)            
        {                
            if (_isOneTimeSetupHandled == false)                
            {                    
                var staticApplicationModules = _dependencyResolver.ResolveAll<IStaticApplicationModule>();
                var sortedModules = staticApplicationModules.SortTopologically();                    
                _handlesMap = sortedModules.ToDictionary(applicationModule =>
                                        _startStaticApplicationModuleService.Start(applicationModule));
                _isOneTimeSetupHandled = true;                
            }                
            return _handlesMap;            
        }        
    }    
}
public static void StartCollection(this IStartDynamicApplicationModuleService startDynamicApplicationModuleService,
            IEnumerable<IDynamicApplicationModule> applicationModules)        
{            
    var sortedModules = applicationModules.SortTopologically();
    foreach (var module in sortedModules)            
    {                
        startDynamicApplicationModuleService.Start(module);            
    }        
}

This functionality enables the developer to define a new module and implement a couple of interfaces and it will be hooked automatically:

internal sealed class ApplicationPathInfo : IApplicationPathInfo    
{        
    public string Executable => "SuperSoftware.Modules.Internal.dll";
    public string RelativePath => Path.Combine("..", "..", "..", "server", "bin");
}

internal sealed class InternalServiceModule : IStaticApplicationModule    
{        
    private readonly IProcessManagementService _processManagementService;
    private readonly IApplicationPathInfo _applicationPathInfo;
    
    public InternalServiceModule(IProcessManagementService processManagementService)        
    {            
        _processManagementService = processManagementService;            
        _applicationPathInfo = new ApplicationPathInfo();        
    }        
    
    public string Id => "Modules.Internal";        
    public string RelativePath => _applicationPathInfo.RelativePath;        
    
    public int Start()        
    {            
        var handle = _processManagementService.Start("dotnet", _applicationPathInfo.Executable);
        Task.Delay(TimeSpan.FromSeconds(5)).Wait();            
        return handle;        
    }        
    
    public void Stop(int handle)        
    {            
        _processManagementService.Stop(handle);        
    }    
}

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:

public class UseLifecycleMiddleware<TBootstrapper> : IMiddleware<TBootstrapper>
        where TBootstrapper : class, IHaveRegistrator, IAssemblySourceProvider    
{        
    private readonly List<IMiddleware<TBootstrapper>> _middlewares = new List<IMiddleware<TBootstrapper>>        
    {            
        new RegisterCollectionMiddleware<TBootstrapper, IDynamicApplicationModule>(),
        new RegisterCollectionMiddleware<TBootstrapper, IStaticApplicationModule>(),
        new RegisterCollectionMiddleware<TBootstrapper, ISetupService>(),
        new RegisterCollectionMiddleware<TBootstrapper, ITeardownService>()
    };       
    
    public TBootstrapper Apply(TBootstrapper @object)        
    {            
        @object.Registrator.AddSingleton<IStartDynamicApplicationModuleService, StartDynamicApplicationModuleService>()
                           .AddSingleton<IStartStaticApplicationModuleService, StartStaticApplicationModuleService>()                
                           .AddSingleton<ILifecycleService, StaticLifecycleService>()
                           .AddSingleton<IStaticSetupService, StaticSetupService>(); 
        MiddlewareApplier.ApplyMiddlewares(@object, _middlewares);
        return @object;        
    }    
}

Last updated