Storyteller needs to be able to interract with your actual system under test. While Storyteller can be used to test against a running application that is initialized separately, most of the time you'll opt to have Storyteller be able to spin up your system on demand as part of running or editing specifications. Storyteller also needs to be able to shut your system down cleanly so that you can swiftly make code changes and rerun specifications.
Storyteller can bootstrap a system with one of two mechanisms:
- In a separate AppDomain.
- In a completely separate process launched by using the dotnet run command against the specification project
Storyteller 3.0 can only support the separate AppDomain
mechanism. Storyteller 4.0 by default uses the separate
process approach, but you can opt into using the AppDomain
approach against a .Net 4.6 application with the
--app-domain
switch from the command line if you are running dotnet storyteller run
or dotnet storyteller open
.
In both cases, Storyteller uses the ISystem
interface described in the next section to control the system under test's
lifecycle.
Understanding the ISystem Interface
The hook in your code that Storyteller uses to govern this lifecycle is the ISystem
interface shown below."
public interface ISystem : IDisposable
{
CellHandling Start();
IExecutionContext CreateContext();
Task Warmup();
}
ISystem
implementation
to use Storyteller against codebases that don't require any kind of bootstrapping.The ISystem
interface only has four methods:
Start() : CellHandling
-- this is a hook to do any kind of system bootstrapping or activation and an opportunity to put together any custom cell conversions and system wide selection lists. This method is only called once each time the testingAppDomain
is loaded.CreateContext() : IExecutionContext
-- this method is executed as the first step in executing a specification. Use this method to perform any globally applicable state setup actions that will apply to all specifications.Dispose()
-- Clean up after yourself! This method is called when the Storyteller application is shut down and anytime Storyteller tries to recycle the system under test.Warmup() : Task
-- For systems that might need a little head start in bootstrapping, this method gives Storyteller a chance to "warm up" the system under test before you run any actual specifications
The IExecutionContext
only lives for the lifetime of a specification execution and is disposed immediately after the specification runs. The Dispose()
method is called even if the specification times out or aborts early with a critical exception.
public interface IExecutionContext : IDisposable
{
// BeforeExecution() is a hook to potentially
// set up state or do any kind of custom logging
void BeforeExecution(ISpecContext context);
// AfterExecution is a hook to gather up custom logging
// or to make custom assertions against the specification
// execution
void AfterExecution(ISpecContext context);
T GetService<T>();
}
Use the Dispose()
method to do any kind of global state cleanup between specification runs. The BeforeExecution(ISpecContext)
and AfterExecution(ISpecContext)
methods can be used to do any kind of per specification set up, clean up, or even to log additional errors and failures for non-functional concerns like performance.
Dispose()
method for your ISystem
is thorough in how it shuts down the system to release resources like file locks, database connections, or network ports. Failing to do this when Storyteller tries to reload the system under test for new changes will cause you no end of grief. And yes, that's the voice of experience talking.
Writing a Custom ISystem for your Application
You can just implement the ISystem
and IExecutionContext
interfaces shown above, but as of Storyteller 4.1 you can also subclass the SimpleSystem
class and override any of the methods shown below:
public class MySystem : SimpleSystem
{
protected override void configureCellHandling(CellHandling handling)
{
// Allows you to apply customizations to CellHandling
}
public override void BeforeEach(SimpleExecutionContext execution, ISpecContext context)
{
// Executes before each specification run
}
public override void AfterEach(ISpecContext context)
{
// Executes after each specification run
// Handy for state cleanup
}
public override Task Warmup()
{
// do any necessary bootstrapping work to get
// your system ready to use
return Task.CompletedTask;
}
public override void Dispose()
{
// any kind of system cleanup when Storyteller is shutting down
}
}
Using the Separate Process Approach
AppDomain
's were dropped from the CoreCLR and won't make a reappearance until Netstandard 2.0. Rather than wait for that
release, Storyteller 4.0 adopted a new default strategy to run the system under test in a separate process triggered from
the dotnet run
command. To use this approach, you need to make your Storyteller project a console application, and in
your Program.Main()
entry point, delegate to the new StorytellerAgent
class like this:
public static class Program
{
public static int Main(string[] args)
{
// If you do not need a custom ISystem
return StorytellerAgent.Run(args);
}
}
If you had a custom ISystem
for your application (named GrammarSystem
in this case),
bootstrapping your application would look like this:
public static class Program
{
public static void Main(string[] args)
{
DateTime time = DateTime.MinValue;
DateTime.TryParse("anything", out time);
Console.WriteLine(time);
DependencyContext.Default.RuntimeLibraries.Each(x =>
{
Console.WriteLine($"{x.Name}: {x.Version}");
});
// GrammarSystem is a custom ISystem
// that "knows" how to bootstrap and
// gracefully shut down the system under test
StorytellerAgent.Run(args, new GrammarSystem());
}
}
Storyteller is no longer able to "auto restart" the running system under test when binaries change. If you are relying on the separate process approach, your workflow is to just recycle or restart the system under test through the Storyteller explorer. There are keyboard shortcuts available to restart the application before rerunning a specification. Do remember that dotnet run will trigger a compilation if necessary, so you can make your changes to code files and immediately recycle Storyteller's system under test.
See The Application Shell for more information on how to trigger the system recycling.
Using the AppDomain Approach
dotnet open
.
Like most testing tools in .Net, the Storyteller application has to open a second .Net AppDomain
in order to load the application assemblies
where they are deployed. This second AppDomain
uses shadow copying
so that Storyteller can remain open as you recompile new changes in the system under test. Storyteller uses a file system
watcher to watch for all changes to files with the .dll
, .exe
, or .config
file extensions. When Storyteller detects changes to these files, Storyteller attempts to:
- Gracefully shutdown the system under test
- Unload the running
AppDomain
for the system under test - Spin up a new
AppDomain
to load the new system binaries - Start the system under test again
You can always explicitly tell Storyteller which ISystem
class to use as a flag to the st run or st open commands. Otherwise, Storyteller uses these rules to determine the ISytem
-- or punt.
- If there are no concrete
ISystem
classes in any assembly in the AppDomain, Storyteller uses a default, nullo system and happily loads all of yourFixture
classes. - If there is exactly one concrete
ISystem
class in the AppDomain, Storyteller uses that one. - If there is more than one concrete
ISystem
class in the AppDomain, Storyteller will throw an exception asking you to explicitly specify which one you want to use.
Custom Conversion Providers, Selection Lists, and Extensions
Custom conversion providers and system wide selection lists can be added in the ISystem.Start()
method. The CellHandling
class below exposes methods to add and configure lists and conversions:
public class CellHandling
{
private readonly IList<IRuntimeConverter> _runtimeConvertors = new List<IRuntimeConverter>();
public CellHandling(EquivalenceChecker equivalence, Conversions conversions)
{
Equivalence = equivalence;
Conversions = conversions;
}
public IEnumerable<IRuntimeConverter> RuntimeConvertors => _runtimeConvertors;
public void RegisterRuntimeConversion<T>() where T : IRuntimeConverter, new()
{
_runtimeConvertors.Add(new T());
}
public EquivalenceChecker Equivalence { get; private set; }
public Conversions Conversions { get; private set; }
public IList<IExtension> Extensions { get; } = new List<IExtension>();
internal readonly LightweightCache<string, OptionList> Lists = new LightweightCache<string, OptionList>(key => new OptionList(key));
public void AddSystemLevelList(string name, IEnumerable<string> values)
{
var list = new OptionList(name);
list.AddValues(values.ToArray());
Lists[name] = list;
}
public void AddSystemLevelList(string name, IEnumerable<Option> options)
{
var list = new OptionList(name);
list.AddOptions(options.ToArray());
Lists[name] = list;
}
public static CellHandling Basic()
{
return new CellHandling(new EquivalenceChecker(), new Conversions());
}
}
To add extensions to your specification system, just add them to the CellHandling.Extensions
list
like so:
public class SeleniumSystem : SimpleSystem
{
protected override void configureCellHandling(CellHandling handling)
{
handling.Extensions.Add(new SeleniumExtension(() => new ChromeDriver()));
}
}