A week or two ago Jeremy Miller posted an article Subcutaneous Testing against React + .Net Applications. It outlined some early R&D on the new Storyteller.Redux, which allows you to run Storyteller tests against Redux stores using WebSockets.

While he had a proven way of communicating between React/Redux and Storyteller, It was lacking the all-important AspNetCore integration that was just casually mentioned. So I figured I would pull on that thread and see what is actually possible.

First Hurdle

My first hurdle was that the code in question was part of Storyteller 5. This does not run on my machine (for various reasons) and so to test anything I needed to backport Storyteller.Redux onto Storyteller 4. (Storyteller.Redux is also only an alpha release currently, anyway).

So I copied the Storyteller.Redux and ReduxSamples into a new solution, and changed the project references to NuGet package references for Storyteller 4. So far so good.

An AspNetCore app

I created another folder (myapp) next to these projects and used the dotnet new reactredux to create a default sample AspNetCore app using react and redux. This would be my test application. This comes with a few pages, and a couple of stores and commands to play with as test targets.

Launching AspNetCore from Storyteller

I couldn't use the Storyteller.AspNetCore package because it actually creates an in-memory server (which is great for some kinds of testing) but to test React, I actually have to launch a browser to point to the app, so a real running instance is required.

Instead, I created a new System that could launch the WebApp using Startup but still hosted on a port that a browser can talk to. I can then use Selenium (again not directly using Storyteller.Selenium for reasons) to launch and host the running page. This required a few custom classes:

    public class BrowserDriver: IDisposable
    {
        private object _browserLock = new object();

        private ChromeDriverService _driverService;
        private ChromeOptions _options;
        private ChromeDriver _driver;

        public BrowserDriver()
        {
            _driverService = ChromeDriverService.CreateDefaultService(PlatformServices.Default.Application.ApplicationBasePath, "chromedriver.exe");
            _options = new ChromeOptions();
            //_options.AddAdditionalCapability("IsJavaScriptEnabled", true);
        }

        public void LaunchUrl(string targetURL)
        {
            if (_driver != null)
            {
                Close();
            }

            _driver = new ChromeDriver(_driverService, _options);

            var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(20));

            _driver.Navigate().GoToUrl(targetURL);

            wait.Until(driver => driver.FindElement(By.TagName("body")));
            wait.Until(driver => ((IJavaScriptExecutor)driver).ExecuteScript("return document.readyState").Equals("complete"));

            IEnumerable<LogEntry> logs = _driver.Manage().Logs.GetLog("browser");

            if (logs.Any(l => l.Level == LogLevel.Warning || l.Level == LogLevel.Severe))
            {
                throw new Exception($"Warnings/Errors logged: \n{string.Join("/n", logs.Select(l => l.Timestamp + ":::" + l.Message))}");
            }
        }

        public void Close()
        {
            if (_driver != null)
            {
                _driver.Quit();
                _driver = null;
            }
        }

        public void Dispose()
        {
            Dispose(true);
            GC.SuppressFinalize(this);
        }

        protected virtual void Dispose(bool disposing)
        {
            if (disposing)
            {
                _driverService?.Dispose();
                _driverService = null;
            }
        }
    }
    public class SeleniumReduxSagaExtension : IExtension
    {
        public string Url { get; }

        private Lazy<BrowserDriver> _browserDriver = new Lazy<BrowserDriver>(() => new BrowserDriver());

        public SeleniumReduxSagaExtension(string url)
        {
            Url = url;
            Server = new WebSocketServer();
        }

        public WebSocketServer Server { get; set; }

        public void Dispose()
        {
            Server.SendCloseMessage();
            Server.Dispose();
            _browserDriver.Value.Dispose();
        }

        public Task Start()
        {
            return Task.Factory.StartNew(() =>
            {
                Server.Start();
            });
        }

        private void LaunchPage()
        {
            var url = Url.Contains("?")
                ? Url + $"&StorytellerPort={Server.Port}"
                : $"{Url}?StorytellerPort={Server.Port}";
            _browserDriver.Value.LaunchUrl(url);
        }

        public void BeforeEach(ISpecContext context)
        {
            Server.SendCloseMessage();
            Server.ClearAll();

            LaunchPage();

            var reduxContext = new ReduxSpecContext(context);
            Server.CurrentContext = reduxContext;

            context.State.Store(reduxContext);
            context.State.Store(Server);

            Server.WaitForConnection(15.Seconds()).Wait();
        }

        public void AfterEach(ISpecContext context)
        {

        }
    }
    public class ReduxSampleSystem : SimpleSystem
    {
        private const string WebHostUrl = "http://localhost:5050";
        private IWebHost _host;

        public ReduxSampleSystem()
        {
            // No request should take longer than 250 milliseconds
            PerformancePolicies.PerfLimit(250, r => r.Type == "Http Request");
        }
        protected override void configureCellHandling(CellHandling handling)
        {
            handling.Extensions.Add(new SeleniumReduxSagaExtension($"{WebHostUrl}/counter"));
        }

        public override Task Warmup()
        {
            Startup.TestDriver = true;
            _host = WebHost.CreateDefaultBuilder()
                    .UseContentRoot(CalculateRelativeContentRootPath())
                    .UseStartup<Startup>()
                    .UseUrls(WebHostUrl)
                    .Build();

            _host.Start();
            
            string CalculateRelativeContentRootPath() =>
              Path.Combine(PlatformServices.Default.Application.ApplicationBasePath,
                 @"..\..\..\..\myapp");

            return base.Warmup();
        }

        public override void Dispose()
        {
            if(_host != null)
            {
                _host.SafeDispose();
            }
            base.Dispose();
        }
    }

Here is some explaination of the interesting parts of this that make it work. To use a startup in a test project, where you are using MVC views and aspx pages, you need a few tweaks.

To finish making the connection work, I had to make a couple of changes to myapp as well:

At this stage, when I run a test, it launches chrome through chromedriver, and the WebSocket connection should start. Storyteller is connected.

Tests

My simplest test:

# Simple sending and value checking

-> id = ab11ba6a-2181-4901-a389-2ef8daff4ee4
-> lifecycle = Acceptance
-> max-retries = 0
-> last-updated = 2017-12-22T13:03:39.1937541Z
-> tags = 

[Calculator]
|> Increment
|> CheckValue number=1
~~~

Which is based on this feature (not too dissimilar to the original sample):

    public class CalculatorFixture : ReduxFixture
    {
        public void GetInitialState()
        {
            this.ForceRefetchOfState().Wait();
        }

        [SendJson("INCREMENT_COUNT")]
        public void Increment()
        {
            
        }

        // SAMPLE: CheckJsonValue
        public IGrammar CheckValue()
        {
            return CheckJsonValue<int>("$.counter.count", "The current counter should be {number}");
        }
        // ENDSAMPLE
    }

To make life easier, there is also an added feature to be able to ForceRefetchOfState, because this isn't currently populated when first connected, you had to issue your first command for it to trigger a refresh of state. (Feature request?) Adding this allows me to forcibly request the initial state. ReduxSamples aside, I think this was the only functional change I actually made to the original Storyteller.Redux project (apart from pulling in a copy of WebSocketsHandler from StorytellerRunner, and switching references to NuGets to make it run).

With this setup, every test first launches a fresh browser window to the target URL. (It also closes any open window first - makes it nice seeing the results of the last run test still open, but that still cleans up after itself.) It may be beneficial to extend this example to also have the navigation ability in a fixture, too.

Results

Have a look at my GitHub repository at github.com/csMACnz/StorytellerReduxSample for a working example of the solution outlined above. At some point, I should feedback the tweaks to Storyteller.Redux (or @jeremydmiller can just steal them...) but until Storyteller 5 is stable, I would keep using my copy, anyway.

Now that selenium is connected, there is also no reason I can't drive UI interaction with the rest of React this way, too. (Although perhaps thorough testing of React is best left up to Jasmine tests...)

Still to do to make this more production ready: add some flags to conditionally compile in or out the reduxharness (WebPack maybe?) so that it is only available in development builds, and not production builds.