I’ve recently worked on a testing framework with Ben Williams (@negativeeddy) that allows you to write your own chat scripts. However, I thought that saving transcripts of your chat from the Bot Emulator would be a really helpful way to produce automated testing. Unfortunately I could not find any out-of-the-box support for running transcript files. Thus the following was born;
Testing with Transcript files
In my example I have the following chat conversation with my Adventure Bot;
USER Examine table BOT The table is old but in good condition. On the table there is a key 'Room Key A'. ACTION Take Room Key A USER Take key BOT You Take key. ACTION look inventory
When you save the transcript file from the Emulator it contains the serialized version of those activities. Here’s a snippet of the above;
{
“type”: “message”,
“text”: “examine table”,
“from”: {
“id”: “5c8620e2-ccd8-4624-9c91-9842610220a4”,
“name”: “User”
},
{
“type”: “message”,
“serviceUrl”: “http://localhost:50090”,
“channelId”: “emulator”,
“from”: {
“id”: “1”,
“name”: “Bot”,
“role”: “bot”
},
“conversation”: {
“id”: “b1bbf220-011b-11e9-aa8d-d94902c621f5|livechat”
},
“recipient”: {
“id”: “5c8620e2-ccd8-4624-9c91-9842610220a4”,
“role”: “user”
},
“text”: “The table is old but in good condition.\nOn the table there is a key ‘Room Key A’.\n”,
“inputHint”: “acceptingInput”,
“suggestedActions”: {
“actions”: [
{
“type”: “imBack”,
“title”: “take Room Key A”,
“value”: “take Room Key A”
}
]
},
Write a test
Now we have the transcript file we want to use it from a test. First create a Test project and get the contents of the file. Note, I’m using MSTest and Moq. I’m taking a hacky short cut and reading the whole file but it should be using streams;
[TestMethod] public async Task TakeKeyFromTranscriptTest() { var transcriptFile = await File.ReadAllTextAsync("Transcripts\\examineTable.transcript"); var activityTranscript = JsonConvert.DeserializeObject(transcriptFile); await TestBotTranscriptAsync(activityTranscript); }
Set-up the test
Now we configure the test and the service-under-test, or in our case – a dialog.
private async Task TestBotTranscriptAsync(List activityTranscript, object request = null) { // create mock version var mockDependency = new Mock(); RecognizerResult recognizerResult = FakeRecognizerResult( "examine table", "Examine", "table"); mockDependency.Setup(x => x.RecognizeAsync( It.Is(i => i.Activity.Text.Equals("Examine table", StringComparison.InvariantCultureIgnoreCase)), It.IsAny())) .Returns(Task.FromResult(recognizerResult)); recognizerResult = FakeRecognizerResult( "take Room Key A", "Take", "key"); // set up mock method mockDependency.Setup(x => x.RecognizeAsync( It.Is(i => i.Activity.Text.Equals("take Room Key A", StringComparison.InvariantCultureIgnoreCase)), It.IsAny())) .Returns(Task.FromResult(recognizerResult)); this.Recognizer = mockDependency.Object; await CreateDialogTest( nameof(AdventureDialog), (id, svc, log) => new AdventureDialog(id, svc, log), options: request) .AssertBotTranscript(activityTranscript) .StartTestAsync(); } private static RecognizerResult FakeRecognizerResult(string query, string intent, string noun) { var resultJson = "{\"text\":\"" + query + "\",\"alteredText\":null,\"intents\":{\"" + intent + "\":{\"score\":0.9696778}},\"entities\":{\"$instance\":{\"noun\":[{\"startIndex\":8,\"endIndex\":13,\"text\":\"" + noun + "\",\"type\":\"noun\",\"score\":0.997288644}]},\"noun\":[\"" + noun + "\"]}}"; var recognizerResult = JsonConvert.DeserializeObject(resultJson); return recognizerResult; }
The Botness of the test is inside the CreateDialogTest method;
public TestFlow CreateDialogTest(string dialogId, Func createDialog, object options = null) where TDialog : Dialog { // setup the bot state for the test var storage = new MemoryStorage(); var conversationState = new ConversationState(storage); var userState = new UserState(storage); var accessors = new AdventureCBCBotAccessors(conversationState, userState) { AdventureState = conversationState.CreateProperty(AdventureCBCBotAccessors.AdventureStateName), DialogState = conversationState.CreateProperty(AdventureCBCBotAccessors.DialogStateName), }; // create the test adapter with any required middleware var adapter = new TestAdapter(sendTraceActivity: true) .Use(new GameServicesMiddleware(accessors.AdventureState)) .Use(new AutoSaveStateMiddleware(conversationState)); // Create or Moq any services ILoggerFactory factory = new LoggerFactory() .AddConsole() .AddDebug(); var services = new BotServices( new BotConfiguration(), TestEndpoint ); if (this.Recognizer != null) { services.LuisServices.Add("LuisAdventureBot", this.Recognizer); } // Create the dialog under test var dialogState = conversationState.CreateProperty("dialogState"); var dialogs = new DialogSet(dialogState); TDialog dialog = createDialog.Invoke(dialogId, services, factory); dialogs.Add(dialog); // Start the test flow-loop to execute the chat return new TestFlow(adapter, async (turnContext, cancellationToken) => { var dc = await dialogs.CreateContextAsync(turnContext, cancellationToken); var result = await dc.ContinueDialogAsync(cancellationToken); if (result.Status == DialogTurnStatus.Complete) { if (result.Result != null) { await turnContext.SendActivityAsync(result.Result.ToString()); } return; } if (!turnContext.Responded) { await dc.BeginDialogAsync(dialog.Id, options, cancellationToken); } }); public EndpointService TestEndpoint { get; set; } = new EndpointService() { Endpoint = "https://example.com/bot" }; }
Execute a test
You may have noticed the lines;
.AssertBotTranscript(activityTranscript) .StartTestAsync();
These are were the execution and testing happen. StartTestAsync
start the test adapter but the AssertBotTranscript
activity extension is where the magic happens. Before we look at that we need to create a couple of helper classes. These classes are responsible for finding all the message activies and to identify which ones are from the user or the bot.
public enum StepActor { User, Bot } public class BotScript { public BotScript(List transcript) { this.Steps = this.ParseScript(transcript); } public string UserName { get; set; } = "User"; public IEnumerable Steps { get; } private IEnumerable ParseScript(List transcript) { foreach (var activity in transcript .SkipWhile(t => t.Type != ActivityTypes.Message || t.From.Name != this.UserName) .Where(t => t.Type == ActivityTypes.Message)) { if (activity.From.Name == this.UserName) { BotStep step = new BotStep { Actor = StepActor.User, Text = activity.Text, }; yield return step; } else { BotStep step = new BotStep { Actor = StepActor.Bot, Text = activity.Text, Actions = activity.SuggestedActions?.Actions.Select(a => a.Title).ToList(), }; yield return step; } } } } public class BotStep { public StepActor Actor { get; internal set; } public string Text { get; internal set; } public List Actions { get; internal set; } }
Now we have our User and Bot steps we can execute the test. This is achieved by enumerating all the steps from the transcripts. If it is a User step then we send it into the Flow. If it’s a Bot step then we assert that the Bot has responded with the expected value in the step.
public static TestFlow AssertBotTranscript(this TestFlow testFlow, List transcript) { var botScript = new BotScript(transcript); return testFlow.AssertBotScript(botScript); } public static TestFlow AssertBotScript(this TestFlow testFlow, BotScript script) { foreach (var step in script.Steps) { if (step.Actor == StepActor.Bot) { testFlow = testFlow.AssertBotStep(step); } else { testFlow = testFlow.Send(step.Text); } } return testFlow; } public static TestFlow AssertBotStep(this TestFlow testFlow, BotStep expectedResponse) { return testFlow.AssertReply(activity => { var messageActivity = activity.AsMessageActivity(); // check main text of message if (!string.IsNullOrEmpty(expectedResponse.Text)) { string fixedExpected = SanitizeText(expectedResponse.Text); string actualResponse = SanitizeText(messageActivity.Text); Assert.AreEqual(fixedExpected, actualResponse); } } } private static string SanitizeText(string text) { string fixedReply = text; if (!string.IsNullOrEmpty(fixedReply)) { fixedReply = fixedReply.Replace("\r", string.Empty).Replace("\n", string.Empty); } return fixedReply; }
All that’s left to is to run the test;
It is just a start
I’ve left out of lot of additional code you need, e.g. handling Adaptive cards, Actions, Hero Cards, etc. but it’s exactly the same idea. You need to flesh out the BotStep class and AssertReply to handle the different forms of message activities. Hopefully, this will just be included in the Framework of the Bot Community framework and you will never need to write this yourself. But for now, this should help you out.